From 884c0889499ecb7d82a80329f9f0b77f065b2a1e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 18:14:34 +0200 Subject: [PATCH 1/3] A machine the network filter drops is said, not skipped in silence (hq issue 188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onTheNetwork resolves every machine unchecked and skipped one whose resolution refused. A machine skipped there has no address, so its own plan fails on the first placeholder that needs one, in another module's words, every seat held on it reads as unheld, and what is built from it cannot be built — four symptoms, none naming the refusal (2026-10-01, the control node, forty minutes). The refusal is now said where it happens, in the resolver's own words. --- cmd/mesh-controller/network.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/mesh-controller/network.go b/cmd/mesh-controller/network.go index dcfccae..e32bba6 100644 --- a/cmd/mesh-controller/network.go +++ b/cmd/mesh-controller/network.go @@ -567,6 +567,9 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory, catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps}, catalogue.World{Unchecked: true, Holdings: holdings}) if err != nil { + // Said, not skipped in silence: a machine dropped here loses its address, and every + // plan that names it fails in another module's words (novox/hq issue 188). + fmt.Fprintf(os.Stderr, "%s is not counted as on the network: it does not resolve: %v\n", p.Name, err) continue } for _, m := range got.Modules { -- 2.54.0 From d94f9e7f9f5a94013785614a7c80c22cfc991a8b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 18:16:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?A=20built-by=20edge=20orders=20and=20gates?= =?UTF-8?q?=20a=20plan;=20it=20never=20widens=20it=20=E2=80=94=20and=20pla?= =?UTF-8?q?ns=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live plan took seventy-five modules along for a controller change: the builder packages the controller's source, everything is built by the builder, so everything was reachable. A module built by the build machine is not changed by a new build machine. Reachability now follows the code and build edges only; built-by still orders a tier after the build machine and gates it on the machine's roll-out. plans stop ends a plan by hand: what was asked still builds and registers, nothing further is asked. --- cmd/mesh-controller/release_plan.go | 24 +++++++++++++++++++++++- cmd/mesh-controller/release_plan_test.go | 24 ++++++++++++++++-------- cmd/mesh-controller/seatverbs.go | 3 +++ internal/catalogue/verbs.go | 5 ++++- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 92521e9..d748461 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -111,7 +111,9 @@ func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool } // reachableFrom is the moved modules plus everything that depends on them, through every layer: -// what a merge rebuilds. +// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine +// is not changed by a new build machine, so a built-by edge orders and gates a plan and never +// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change. func reachableFrom(moved []string, edges []inventory.Edge) []string { in := map[string]bool{} for _, m := range moved { @@ -120,6 +122,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string { for grew := true; grew; { grew = false for _, e := range edges { + if e.Kind == inventory.EdgeBuiltBy { + continue + } if in[e.To] && !in[e.From] { in[e.From] = true grew = true @@ -571,6 +576,23 @@ func plansCommand(ctx context.Context, args []string) error { } return nil } + if len(positionals) == 2 && positionals[0] == "stop" { + p, err := inv.PlanByID(ctx, positionals[1]) + if err != nil { + return err + } + if !p.Open() { + return fmt.Errorf("%s is already %s", p.ID, p.State) + } + p.State = inventory.PlanFailed + p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier) + if err := inv.SavePlan(ctx, p); err != nil { + return err + } + fmt.Printf("%s stopped at tier %d of %d; what was asked still builds and registers, nothing further is asked\n", + p.ID, p.Tier, len(p.Tiers)) + return nil + } plans, err := inv.RecentPlans(ctx, *limit) if err != nil { return err diff --git a/cmd/mesh-controller/release_plan_test.go b/cmd/mesh-controller/release_plan_test.go index 554f378..c2eeb27 100644 --- a/cmd/mesh-controller/release_plan_test.go +++ b/cmd/mesh-controller/release_plan_test.go @@ -18,6 +18,7 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { {From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared}, // a code dependency: the proxy packages the controller's source — same tier {From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages}, + {From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages}, // runtime dependencies: everything source-built is built by the builder {From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy}, @@ -30,7 +31,9 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { } // The runtime image moved: everything on it, and what is built by what is on it. set := reachableFrom([]string{"mesh-tools"}, edges) - want := []string{"builder", "mesh-controller", "mesh-tools", "postgres", "route-proxy", "shop", "shop-plugin"} + // What stands on the runtime, and the builder that stands on it; not the controller, which the + // builder merely builds, nor the proxy that packages the controller. + want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"} if len(set) != len(want) { t.Fatalf("reachable from the runtime: %v, want %v", set, want) } @@ -44,26 +47,31 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { if pos["mesh-tools"] != 0 || pos["builder"] != 1 { t.Fatalf("the runtime then the builder: %v", tiers) } - if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"] && pos["mesh-controller"] > pos["builder"]) { + if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) { t.Fatalf("what the builder builds comes after the builder: %v", tiers) } if pos["shop-plugin"] <= pos["shop"] { t.Fatalf("a plugin after what it is declared on: %v", tiers) } - if pos["route-proxy"] != pos["mesh-controller"] { - t.Fatalf("a code dependency is rebuilt in the same tier as its source: %v", tiers) - } if hasCycle(tiers, edges) { t.Fatalf("no cycle here: %v", tiers) } // The controller alone moved: the proxy with it, nothing else. small := reachableFrom([]string{"mesh-controller"}, edges) - if len(small) != 2 { + if len(small) != 3 { t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small) } - if tiers := tiersOf(small, edges); len(tiers) != 1 { - t.Fatalf("both in one tier: %v", tiers) + // The builder packages the controller's source (same tier by that edge) and the controller is + // built by the builder (next tier by that one): the builder first, then the controller and the + // proxy together — a code dependency in one tier, a runtime dependency across tiers. + smallTiers := tiersOf(small, edges) + if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 { + t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers) + } + // The builder alone moved: the builder, and nothing it builds. + if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 { + t.Fatalf("a build machine change rebuilds the build machine alone: %v", only) } // Only a runtime dependency gates on deployment, and only when the module rolls out. diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index e95a5dc..08922ca 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -70,6 +70,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return []string{"builds"}, nil case "plans": + if id := str("stop"); id != "" { + return []string{"plans", "stop", id}, nil + } if id := str("id"); id != "" { return []string{"plans", id}, nil } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 71aeea4..0f1a473 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -93,7 +93,10 @@ var ControllerVerbs = []Verb{ }, nil)}, {Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " + "the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.", - Input: schema(map[string]string{"id": "a plan's id (as `plans` lists them): that plan, tier by tier"}, nil)}, + Input: schema(map[string]string{ + "id": "a plan's id (as `plans` lists them): that plan, tier by tier", + "stop": "a plan's id: stop it — what was asked still builds, nothing further is asked", + }, nil)}, {Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.", Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, {Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.", -- 2.54.0 From dcf627852340fdd026475cbea16009caf06280de Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 1 Oct 2026 18:22:17 +0200 Subject: [PATCH 3/3] The rest of the mesh resolves each machine with its own pins, and says which machine it leaves out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second pass of theRestOfTheMesh resolved every machine without its pins. Since a machine with two providers of one provision is refused unless a pin names one (195/196), the control node was refused there and vanished: every seat it holds read as unheld, the build machine refused what needs the git seat, the roll-out was refused — and nothing said why (hq issue 188). Each machine is now resolved as its plan resolves it, with its pins; a machine left out is named with the resolver's words. --- cmd/mesh-controller/plan.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 46d269b..f5acc6a 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -7,6 +7,7 @@ import ( "errors" "flag" "fmt" + "os" "sort" "strings" @@ -281,8 +282,9 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings}) if err != nil { - // Their set does not resolve for some other reason. Not this node's problem to - // report, and nothing of theirs is running, so it offers nothing. + // Said, not skipped: a machine dropped here offers nothing and holds nothing as far + // as every other machine's plan can tell (novox/hq issue 188). + fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err) continue } firstHeld = append(firstHeld, got.Claims...) @@ -313,8 +315,17 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings} var held []catalogue.Held for _, o := range others { - got, err := catalogue.Resolve(shelf, o.assigned, o.node, world) + // Each machine is resolved with its own pins, as its plan is: a machine that needs one to + // settle two providers would otherwise be refused here and vanish from the mesh — every + // seat it holds unheld, every build that needs one refused (2026-10-01, the control node; + // novox/hq issue 188). + theirs := world + if pins, err := inv.PinsFor(ctx, o.node.Name); err == nil { + theirs.Pinned = pins + } + got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs) if err != nil { + fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err) continue } held = append(held, got.Claims...) -- 2.54.0