diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 4a770d7e..78d75c1e 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -387,10 +387,7 @@ var plainWordings = map[string]func(conditions.Observation) words{ Resolved: "Resolved: " + m + " runs a good build again"} }), "release-held": worded(func(o conditions.Observation) words { - return words{Headline: "Updates wait for your release", - Needs: "release them, or leave them held.", - Explanation: "Some module updates wait for a person to release them, and are not delivered until then.", - Resolved: "Resolved: the held updates are released"} + return releaseHeldWords(nil, o.Also) }), "facts-stale": worded(func(o conditions.Observation) words { return words{Headline: "Merge checks use outdated facts", @@ -402,21 +399,21 @@ var plainWordings = map[string]func(conditions.Observation) words{ // The controller and the core. "controller-deaf": worded(func(o conditions.Observation) words { return words{Headline: "The controller stopped listening", - Needs: "restart the controller if this stays.", + Needs: "restart the controller if this stays, " + FromMeshMCPServer, Explanation: "The controller, which coordinates the mesh, has taken no messages for minutes while some " + "wait. Changes and repairs do not happen until it recovers.", Resolved: "The controller listens again"} }), "self-check-silent": worded(func(o conditions.Observation) words { return words{Headline: "The mesh's self-check stopped", - Needs: "restart the controller if this stays.", + Needs: "restart the controller if this stays, " + FromMeshMCPServer, Explanation: "The self-check, which looks over the whole mesh every few minutes, has not finished a run. " + "Problems may go unnoticed until it runs again.", Resolved: "The self-check runs again"} }), "watchdogs-silent": worded(func(o conditions.Observation) words { return words{Headline: "The mesh's watchdogs stopped", - Needs: "restart the controller if this stays.", + Needs: "restart the controller if this stays, " + FromMeshMCPServer, Explanation: "The watchdogs, which notice when something expected does not happen, have not run, so " + "missed signals are not noticed.", Resolved: "The watchdogs run again"} @@ -683,6 +680,32 @@ func moduleNeeds(node string, rs []inventory.ResourceHealth) string { // authorised (to-be 46 phases 5 and 6). const FromMeshMCPServer = "from the mesh MCP server; this notification cannot do it." +// releaseHeldWords are the plain words of updates held after a walk failed: which modules wait, +// on which machines, and where the operator releases them (ADR 0258: a release is not an acknowledgement, so +// no notification gives it). Raised with the modules and machines (backlogObservation); the kind's fallback +// knows neither. +func releaseHeldWords(modules, machines []string) words { + what := "Some module updates" + headline := "Updates wait for your release" + if len(modules) > 0 { + what = "Updates of " + namesWords(modules, 3) + if h := what + " wait for your release"; len(h) <= conditions.HeadlineMax { + headline = h + } else if h := fmt.Sprintf("%d module updates wait for your release", len(modules)); len(h) <= conditions.HeadlineMax { + headline = h + } + } + where := "" + if len(machines) > 0 { + where = " on " + namesWords(machines, 4) + } + return words{Headline: headline, + Needs: "release them " + FromMeshMCPServer, + Explanation: fmt.Sprintf("%s%s wait for a person to release them, because the last walk failed."+ + " They are not delivered until then, and stay held if you leave them.", what, where), + Resolved: "Resolved: the held updates are released"} +} + // reloginNeeds is what an account waiting for its groups needs (ADR 0252). func reloginNeeds(node string) string { return fmt.Sprintf("log out of %s completely and log in again, or restart it.", node) diff --git a/cmd/mesh-controller/plain_words_test.go b/cmd/mesh-controller/plain_words_test.go index a24290df..c0626897 100644 --- a/cmd/mesh-controller/plain_words_test.go +++ b/cmd/mesh-controller/plain_words_test.go @@ -1,6 +1,7 @@ package main import ( + "regexp" "strings" "testing" "time" @@ -222,3 +223,89 @@ func TestDataLossOffersNoSilence(t *testing.T) { } } } + +// **Updates held after a failed release** (2026-10-08): the popup read "Needs you: release them, or leave +// them held." — naming neither what waits nor where it is released. It names the modules and machines, and +// the mesh MCP server. +func TestUpdatesHeldNameWhatWaitsAndWhereItIsReleased(t *testing.T) { + saved := backlogNow + t.Cleanup(func() { backlogNow = saved }) + backlogNow.held = "release-1791457717307061152 failed (failed its gate on g14); what waits is released again by a person" + backlogNow.waiting = map[string][]inventory.CarriedMove{ + "shanks": {{Module: "openrazer"}}, + "g14": {{Module: "openrazer"}, {Module: "sensors"}}, + } + got := backlogObservation() + if len(got) != 1 { + t.Fatalf("raised %d", len(got)) + } + plainExample(t, got[0], "Updates of openrazer and sensors wait for your release", + "Needs you: release them from the mesh MCP server; this notification cannot do it. Updates of openrazer and "+ + "sensors on g14 and shanks wait for a person to release them, because the last walk failed. "+ + "They are not delivered until then, and stay held if you leave them.") +} + +// **What held them is said in the glossary's words** (2026-10-08 review): the backlog is held after any +// walk failed, not a release, and a "check" is a pull request's status. So the words say the walk failed, +// and never that a release failed or a check did — with the modules and machines named or not. +func TestUpdatesHeldSayTheWalkFailed(t *testing.T) { + for _, w := range []words{ + releaseHeldWords([]string{"openrazer"}, []string{"g14"}), + releaseHeldWords(nil, nil), + plainWordings["release-held"](conditions.Observation{Scope: conditions.ScopeMesh, ID: "release"}), + } { + if !strings.Contains(w.Explanation, "because the last walk failed.") { + t.Errorf("does not say the walk failed: %q", w.Explanation) + } + for _, wrong := range []string{"release failed", "check"} { + if strings.Contains(w.Explanation, wrong) { + t.Errorf("says %q: %q", wrong, w.Explanation) + } + } + } +} + +// mcpVerb is a need that opens with a verb only the mesh MCP server performs (ADR 0258 §1): release, stop, +// start, restart, and a restore. +var mcpVerb = regexp.MustCompile(`^(release|stop|start|restart|restore)\b`) + +// **A need no notification can answer says where it is answered** (ADR 0258 §1): every wording whose need +// opens with a verb the mesh MCP server performs, and offers no action, ends with FromMeshMCPServer — the +// kinds worded here for every subject shape, and those worded where they are raised. A new kind that misses +// it fails here; release-held did (2026-10-08). +func TestANeedNoNotificationAnswersNamesTheMeshMCPServer(t *testing.T) { + check := func(what string, w words) { + t.Helper() + if w.Needs == "" || len(w.Actions) > 0 || !mcpVerb.MatchString(w.Needs) { + return + } + if !strings.HasSuffix(w.Needs, FromMeshMCPServer) { + t.Errorf("%s needs %q without %q", what, w.Needs, FromMeshMCPServer) + } + } + subjects := []conditions.Observation{ + {Scope: conditions.ScopeMachine, ID: "ace", Machine: "ace"}, + {Scope: conditions.ScopeMachine, ID: "ace.immich.library", Machine: "ace"}, + {Scope: conditions.ScopeModule, ID: "openrazer.g14", Machine: "g14"}, + {Scope: conditions.ScopeDelivery, ID: "novox/hq@055550802096"}, + {Scope: conditions.ScopeCore, ID: "controller.anchor", Machine: "anchor"}, + {Scope: conditions.ScopeMesh, ID: "release", Also: []string{"g14"}}, + } + for kind, fn := range plainWordings { + for _, s := range subjects { + for _, sev := range []conditions.Severity{conditions.Warning, conditions.Urgent} { + s.Kind, s.Severity, s.Resolver = kind, sev, conditions.ResolverOperator + check(kind+" about "+s.ID, fn(s)) + } + } + } + check("a walk waiting", words{Needs: waitingNeeds(conditions.Urgent)}) + check("a module's failed service", words{Needs: moduleNeeds("g14", + []inventory.ResourceHealth{{Kind: link.KindUnit, Target: "openrazer-daemon.service"}})}) + for _, state := range []string{"held", "ready", "failing"} { + _, _, _, needs, actions := stalledWords(stalledLine{ID: "novox/hq@055550802096", State: state, For: "36h"}, + conditions.Observation{Resolver: conditions.ResolverOperator}) + check("a delivery "+state, words{Needs: needs, Actions: actions}) + } + check("updates held", releaseHeldWords([]string{"openrazer"}, []string{"g14"})) +} diff --git a/cmd/mesh-controller/release.go b/cmd/mesh-controller/release.go index c1667ec2..421a01f8 100644 --- a/cmd/mesh-controller/release.go +++ b/cmd/mesh-controller/release.go @@ -710,15 +710,27 @@ func backlogObservation() []conditions.Observation { } n := 0 var machines []string + seen := map[string]bool{} + var modules []string for node, moves := range backlogNow.waiting { n += len(moves) machines = append(machines, node) + for _, mv := range moves { + if !seen[mv.Module] { + seen[mv.Module] = true + modules = append(modules, mv.Module) + } + } } sort.Strings(machines) - return []conditions.Observation{{Scope: conditions.ScopeMesh, ID: "release", Token: "held", Kind: "release-held", - Severity: conditions.Warning, Resolver: conditions.ResolverOperator, + sort.Strings(modules) + o := conditions.Observation{Scope: conditions.ScopeMesh, ID: "release", Token: "held", Kind: "release-held", + Severity: conditions.Warning, Resolver: conditions.ResolverOperator, Also: machines, Summary: fmt.Sprintf("%d build move(s) on %s wait for a gate and are not released: %s — `upgrade backlog` lists "+ - "them, `upgrade release-backlog --why …` releases them", n, strings.Join(machines, ", "), backlogNow.held)}} + "them, `upgrade release-backlog --why …` releases them", n, strings.Join(machines, ", "), backlogNow.held)} + w := releaseHeldWords(modules, machines) + o.Headline, o.Explanation, o.Resolved, o.Needs = w.Headline, w.Explanation, w.Resolved, w.Needs + return []conditions.Observation{o} } // backlogCommand is `upgrade backlog`, read-only, and `upgrade release-backlog --why`.