diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 54befa7f..9e92bffd 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -374,10 +374,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", @@ -389,21 +386,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"} @@ -670,6 +667,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 release failed its gate: 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 release failed its "+ + "check. 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..74b19716 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,69 @@ 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 release failed its check. "+ + "They are not delivered until then, and stay held if you leave them.") +} + +// 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`.