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 { 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...) 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.",