From e33da2dc1cf980c86cc6577a3a9eac8538b58a74 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:44:02 +0200 Subject: [PATCH 1/2] Name what a held release holds, and where it is released The release-held words said "release them, or leave them held" without the modules, the machines or the mesh MCP server, so the operator could neither tell what waited nor where to act (ADR 0258). The controller's restart needs missed the same suffix. A test now holds every need that opens with a verb only the mesh MCP server performs to name it, so a new kind cannot miss it. --- cmd/mesh-controller/plain_words.go | 37 +++++++++++--- cmd/mesh-controller/plain_words_test.go | 67 +++++++++++++++++++++++++ cmd/mesh-controller/release.go | 18 +++++-- 3 files changed, 112 insertions(+), 10 deletions(-) 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`. From 298ec06ae0aadaec63b123ea902e70bfd03754c3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 17:02:30 +0200 Subject: [PATCH 2/2] Say held updates wait because a walk failed, in the glossary's words The backlog is held after any failed walk, not only a release, and a check is a pull request's status; the words said the last release failed its check. --- cmd/mesh-controller/plain_words.go | 6 +++--- cmd/mesh-controller/plain_words_test.go | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/cmd/mesh-controller/plain_words.go b/cmd/mesh-controller/plain_words.go index 9e92bffd..5a2938da 100644 --- a/cmd/mesh-controller/plain_words.go +++ b/cmd/mesh-controller/plain_words.go @@ -667,7 +667,7 @@ 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, +// 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. @@ -688,8 +688,8 @@ func releaseHeldWords(modules, machines []string) words { } 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), + 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"} } diff --git a/cmd/mesh-controller/plain_words_test.go b/cmd/mesh-controller/plain_words_test.go index 74b19716..c0626897 100644 --- a/cmd/mesh-controller/plain_words_test.go +++ b/cmd/mesh-controller/plain_words_test.go @@ -241,10 +241,30 @@ func TestUpdatesHeldNameWhatWaitsAndWhereItIsReleased(t *testing.T) { } 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. "+ + "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`)