From 12dd2bb3f0a08e55db6b046f97a22ebbbeac4b98 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 22:15:18 +0200 Subject: [PATCH] A module the catalogue no longer holds ends deleted instead of failing the plan; the unsent hint names release-backlog (issue 1922) --- cmd/mesh-controller/release_plan.go | 12 ++--- cmd/mesh-controller/removed_module_test.go | 58 ++++++++++++++++++++++ cmd/mesh-controller/rollout_first_test.go | 2 +- 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 cmd/mesh-controller/removed_module_test.go diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index fa254d87..9ed917a6 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -328,8 +328,8 @@ func applied(module string, builtAt time.Time, running []string, reports []inven // askTier asks the build machine for the named modules of the plan's tier — all of it, or what of it is still // unasked (novox/hq issue 754) — and marks each asked. A module -// the catalogue no longer holds, or whose ask could not be made, is a failure of the plan: a tier -// half asked is a tier that will never complete. +// the catalogue no longer holds ends as deleted (issue 1922); one whose ask could not be made is a failure of the +// plan: a tier half asked is a tier that will never complete. func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan, names []string) error { entries, err := inv.Catalogued(ctx) if err != nil { @@ -390,10 +390,10 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s } e, known := byName[name] if !known { - state.State = "failed" + // **A module the catalogue no longer holds ends as deleted** (issue 1922): the plan's own merge removed it, so + // there is nothing to build or send; it is no failure, and a retry of a plan failed that way resumes. + state.State = planDeleted state.Why = "no longer in the catalogue" - p.State = inventory.PlanFailed - p.Note = name + " is no longer in the catalogue" return } // **Every module is taken from the builder's done key, asking no build** (novox/hq ADR 0503 §6, ADR 0501, ADR 0453 §5; @@ -1647,7 +1647,7 @@ func sayUnsent(p *inventory.Plan, rollsOut func(string) bool) { return } sort.Strings(unsent) - p.Note += "; built and never sent: " + strings.Join(unsent, ", ") + " — `push --behind` sends them" + p.Note += "; built and never sent: " + strings.Join(unsent, ", ") + " — `upgrade release-backlog --why ` releases them" } // planLine is one plan as `status` says it, given the least bound of a tier. diff --git a/cmd/mesh-controller/removed_module_test.go b/cmd/mesh-controller/removed_module_test.go new file mode 100644 index 00000000..298949d8 --- /dev/null +++ b/cmd/mesh-controller/removed_module_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/inventory" +) + +// 1922/AC1: a tier naming a module the catalogue no longer holds asks it without failing the plan: the module ends +// as deleted (not built, not sent) and the tier's other modules go on. +func TestAModuleTheCatalogueNoLongerHoldsEndsDeletedAndTheTierGoesOn(t *testing.T) { + p, byName := aWalkOfOne("sudo") + p.Tiers = [][]string{{"gone", "sudo"}} + for _, m := range p.Tiers[0] { + askModule(t.Context(), p, m, byName) + } + if g := p.Modules["gone"]; g == nil || g.State != planDeleted || g.Build != "" || g.SentAt != nil { + t.Fatalf("the removed module is not deleted: %+v", g) + } + if p.State == inventory.PlanFailed { + t.Fatalf("the plan failed: %q", p.Note) + } + if s := p.Modules["sudo"]; s == nil || s.State != "asked" { + t.Fatalf("the other module of the tier did not go on: %+v", s) + } +} + +// 1922/AC2: a plan that failed on such a module before the fix is retried: the removed module ends deleted and the +// plan is building again, to go on to its next tier. +func TestARetryOfAPlanFailedOnARemovedModuleResumes(t *testing.T) { + p := inventory.Plan{ID: "plan-1", State: inventory.PlanFailed, Note: "gone is no longer in the catalogue", + Tiers: [][]string{{"gone"}, {"next"}}, + Modules: map[string]*inventory.PlanModule{ + "gone": {State: "failed", Why: "no longer in the catalogue"}, + }} + failed := toRetry(&p, nil, nil) + if len(failed) != 1 || failed[0] != "gone" { + t.Fatalf("the failed set is %v", failed) + } + for _, m := range failed { + askModule(t.Context(), &p, m, map[string]inventory.Entry{}) + } + resumed(&p, "retried") + if p.State != inventory.PlanBuilding || p.Modules["gone"].State != planDeleted { + t.Fatalf("the retry did not resume: %s %+v", p.State, p.Modules["gone"]) + } +} + +// 1922/AC3: an ended plan's note names the verb that releases the modules waiting for a gate, not `push --behind`. +func TestAnEndedPlansHintNamesTheReleaseOfTheBacklog(t *testing.T) { + p := inventory.Plan{State: inventory.PlanFailed, Note: "failed", + Modules: map[string]*inventory.PlanModule{"agent": {State: "built"}}} + sayUnsent(&p, func(string) bool { return true }) + if strings.Contains(p.Note, "push --behind") || !strings.Contains(p.Note, "upgrade release-backlog") { + t.Fatalf("the note reads %q", p.Note) + } +} diff --git a/cmd/mesh-controller/rollout_first_test.go b/cmd/mesh-controller/rollout_first_test.go index 644f4be0..02996756 100644 --- a/cmd/mesh-controller/rollout_first_test.go +++ b/cmd/mesh-controller/rollout_first_test.go @@ -146,7 +146,7 @@ func TestAnEndedPlanSaysWhatItBuiltAndNeverSent(t *testing.T) { rollsOut := func(m string) bool { return m != "notes" } sayUnsent(&p, rollsOut) sayUnsent(&p, rollsOut) - if p.Note != "closed by hand at tier 1; built and never sent: agent — `push --behind` sends them" { + if p.Note != "closed by hand at tier 1; built and never sent: agent — `upgrade release-backlog --why ` releases them" { t.Fatalf("the note reads %q", p.Note) } }