Files
mesh-controller/cmd/mesh-controller/release.go
T
jochen e33da2dc1c
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
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.
2026-10-08 16:44:02 +02:00

879 lines
30 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// No build reaches a machine without a gate (novox/hq ADR 0236).
//
// **A send carries the machine's whole declaration.** A plan sending one module to its first machine
// sends every other module whose build moved there too; a cascade, a healer's resend and a whole-mesh
// push do the same. Under the old default (`record`) builds were registered and sent nowhere, so on the
// day the default became `roll` every machine was behind on dozens of builds no gate had seen — and the
// next send of anything would have restarted all of them at once, on every machine.
//
// So **a build moves on a machine only through a send that judges it there**, or a person's:
//
// - a gated send — a plan's first machine, a release plan's machine — carries every move waiting on that
// machine, and its gate judges each of them; a pass is each build's verdict, a failure puts back what
// failed;
// - a module a send exists for may move anywhere it is sent: a policy of *together*, a rollback, a build
// whose gate already passed;
// - a send naming a machine by a person (`push <node>`, the bus step) carries what it carries;
// - every other send — a plan's "rest", a cascade, a healer's, the bus's user list carried — is refused,
// or leaves the machine, while a move there waits for a gate. A rebuild that made the same artifacts
// from the same manifest is no move.
//
// **The release plan** is what walks the waiting moves through: whenever moves wait for a gate that no
// started plan is walking, the mesh opens one — every such machine, one at a time, the control node last,
// each sent and judged before the next. A release plan that fails stops the next one opening on its own:
// a person releases it again (`upgrade release-backlog --why`), after the condition says why.
// sendScope is what a send may carry that no gate has seen.
type sendScope struct {
// judged are the machines whose every move this send's gate judges.
judged map[string]bool
// modules are those a send exists for, which may move wherever it goes.
modules map[string]bool
// person is a person's act: it carries what it carries.
person bool
}
type sendScopeKey struct{}
func withScope(ctx context.Context, s sendScope) context.Context {
return context.WithValue(ctx, sendScopeKey{}, s)
}
func scopeOf(ctx context.Context) sendScope {
s, _ := ctx.Value(sendScopeKey{}).(sendScope)
return s
}
// errUngated is a send refused because it would carry a build no gate has seen.
var errUngated = errors.New("a build waits there for its gate")
// errWalkedElsewhere is a gated send refused because a plan that has started walks a move there.
var errWalkedElsewhere = errors.New("a plan already walking a build there sends it")
// moveFacts is what tells a move from a rebuild, and a gated build from one no gate has seen.
type moveFacts struct {
current map[string]inventory.CurrentBuild
fps map[string]map[string]string
// srcs is, per module, per commit, its build's source fingerprint (novox/hq issue 280).
srcs map[string]map[string]string
passed map[string]map[string]bool
plans []inventory.Plan
}
func readMoveFacts(ctx context.Context, inv *inventory.Inventory) (moveFacts, error) {
var f moveFacts
var err error
if f.current, err = inv.CurrentBuilds(ctx); err != nil {
return f, err
}
if f.fps, err = inv.Fingerprints(ctx); err != nil {
return f, err
}
if f.srcs, err = inv.SourceFingerprints(ctx); err != nil {
return f, err
}
if f.passed, err = inv.PassedCommits(ctx); err != nil {
return f, err
}
f.plans, err = inv.OpenPlans(ctx)
return f, err
}
// identical is whether two builds of a module put the same thing on a machine: the same commit,
// builds made from the same source (novox/hq issue 280) — the module's tree, the contexts it read, its
// bases and toolchains, whatever digests an image rebuild made of them — or builds that made the same
// artifacts from the same manifest.
func (f moveFacts) identical(module, a, b string) bool {
if a == b || sameCommit(a, b) {
return true
}
if sa := f.srcs[module][a]; sa != "" && sa == f.srcs[module][b] {
return true
}
fa := f.fps[module][a]
return fa != "" && fa == f.fps[module][b]
}
// gated is whether a build of a module from this commit — or one identical to it — passed a gate.
func (f moveFacts) gated(module, commit string) bool {
for c := range f.passed[module] {
if f.identical(module, c, commit) {
return true
}
}
return false
}
// unchangedOnEvery is whether a plan's build of a module was made from the source of the build every
// machine running it was last sent (novox/hq issue 280): no move on any of them. False when no machine
// runs it, when what one was sent is not known, or when the build stands for itself.
func unchangedOnEvery(ctx context.Context, inv *inventory.Inventory, module, build string, running []string) (bool, error) {
if build == "" || len(running) == 0 {
return false, nil
}
same, err := inv.SameSourceCommits(ctx, module, build)
if err != nil || len(same) == 0 {
return false, err
}
for _, n := range running {
sent, known, err := inv.SentBuilds(ctx, n)
if err != nil {
return false, err
}
was, carried := sent[module]
if !known || !carried || !inRun(same, was) {
return false, nil
}
}
return true, nil
}
// inRun is whether a commit is one of a run's, however either is abbreviated.
func inRun(run map[string]bool, commit string) bool {
for c := range run {
if sameCommit(c, commit) {
return true
}
}
return false
}
// moves is what a machine's next send would move that no gate has seen: modules whose policy rolls out
// (a recorded one is a person's push), that the machine was sent before, moving to a build not identical
// to the one it runs and that has passed no gate. A machine whose last send's builds are not known names
// none: the cascade holds it whole (ADR 0221).
//
// With all, a move to a build that passed a gate elsewhere counts too: what a gated send carries.
func (f moveFacts) moves(node string, modules []string, sent map[string]string, known, all bool) []inventory.CarriedMove {
if !known {
return nil
}
var out []inventory.CarriedMove
for _, m := range modules {
now := f.current[m]
was, carried := sent[m]
if !now.RollOut || !carried || f.identical(m, was, now.Commit) || (!all && f.gated(m, now.Commit)) {
continue
}
out = append(out, inventory.CarriedMove{Module: m, Node: node, From: was, To: now.Commit})
}
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
return out
}
// walkedBy is the open plan that has started walking a module's build — sent it to a first machine,
// not yet passed — other than to this machine; empty when none does.
func (f moveFacts) walkedBy(module, node string) string {
for _, p := range f.plans {
s, holds := p.Modules[module]
if !p.Open() || !holds || s == nil || s.FirstAt == nil || s.SentAt != nil || slices.Contains(s.First, node) {
continue
}
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
continue
}
return p.ID
}
return ""
}
// machineMoves is the moves no gate has seen on one machine, read from what it would be sent now.
var machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
plan, _, err := planFor(ctx, open, node)
if err != nil {
return nil, nil // it cannot be worked out: the send says why
}
modules := make([]string, 0, len(plan.Modules))
for _, m := range plan.Modules {
modules = append(modules, m.Module)
}
sent, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
return f.moves(node, modules, sent, known, all), nil
}
// ungatedIn refuses a send whose machines would move a build no gate has seen, outside its scope: the
// machines named, and why; the machine holding the bus, added only for its user list, is left instead.
func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder string) ([]string, error) {
scope := scopeOf(ctx)
if scope.person {
return names, nil
}
f, err := readMoveFacts(ctx, open.inventory)
if err != nil {
return nil, err
}
var kept, refused []string
for _, n := range names {
moves, err := machineMoves(ctx, open, f, n, false)
if err != nil {
return nil, err
}
var waiting []string
for _, mv := range moves {
if !scope.judged[n] && !scope.modules[mv.Module] {
waiting = append(waiting, fmt.Sprintf("%s %s → %s", mv.Module, short(mv.From), short(mv.To)))
}
}
switch {
case len(waiting) == 0:
kept = append(kept, n)
case n == addedHolder:
fmt.Printf("%s holds the bus and its user list changed, and it is not sent now: %s wait there for a gate "+
"— the bus may refuse what was newly granted until it is\n", n, strings.Join(waiting, ", "))
default:
refused = append(refused, fmt.Sprintf("%s (%s)", n, strings.Join(waiting, ", ")))
}
}
if len(refused) > 0 {
return nil, fmt.Errorf("%w: %s — a walk sends them, one machine at a time, each judged (novox/hq ADR "+
"0236); `upgrade backlog` lists them", errUngated, strings.Join(refused, "; "))
}
return kept, nil
}
// gatedSend sends one machine everything waiting there, under a gate that judges it all: what moved is
// answered, with the build each moved to, for the gate to judge and to put back. Refused while a plan that
// has started walks one of those builds elsewhere: that plan sends it here once its gate passed.
//
// owns are the moves the send exists for — every module of a plan's tier whose first machine this is, in
// one send (novox/hq issue 281): a send carries the machine's whole declaration (ADR 0221), so a send per
// module was the same declaration sent again and again, each one setting aside the one before.
func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.CarriedMove) ([]inventory.CarriedMove, []string, error) {
inv := open.inventory
f, err := readMoveFacts(ctx, inv)
if err != nil {
return nil, nil, err
}
moves, err := machineMoves(ctx, open, f, node, true)
if err != nil {
return nil, nil, err
}
own := func(module string) bool {
return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module })
}
for _, mv := range moves {
if own(mv.Module) {
continue
}
if id := f.walkedBy(mv.Module, node); id != "" {
return nil, nil, fmt.Errorf("%w: %s's build %s waits on %s, which %s is walking", errWalkedElsewhere,
mv.Module, short(mv.To), node, id)
}
}
for _, o := range owns {
i := slices.IndexFunc(moves, func(mv inventory.CarriedMove) bool { return mv.Module == o.Module })
switch {
case i < 0:
moves = append(moves, o)
case moves[i].Build == "":
moves[i].Build = o.Build
}
}
if len(moves) == 0 && len(owns) == 0 {
return nil, nil, nil
}
for i := range moves {
if moves[i].Build == "" {
if moves[i].Build, err = inv.BuildOf(ctx, moves[i].Module, moves[i].To); err != nil {
return nil, nil, err
}
}
}
sort.Slice(moves, func(i, j int) bool { return moves[i].Module < moves[j].Module })
sayRecreations(ctx, open, moves)
sent, err := sendRollout(withScope(ctx, sendScope{judged: map[string]bool{node: true}}), open, []string{node})
if err != nil {
return nil, nil, err
}
return moves, sent, nil
}
// passCarried keeps a pass as the verdict of every build the gate judged beside its own module.
func passCarried(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, except string) {
seen := map[string]bool{}
for _, c := range g.Carried {
if c.Module == except || c.Build == "" || seen[c.Build] {
continue
}
seen[c.Build] = true
if err := open.inventory.RecordGate(ctx, inventory.GateVerdict{Build: c.Build, Module: c.Module, Commit: c.To,
Previous: c.From, Plan: p.ID, Machines: []string{c.Node}, Verdict: inventory.GatePassed, Why: whyFor(g, c.Module),
Component: coreComponent(c.Module), JudgingFrom: g.Since}); err != nil {
fmt.Printf("%s: %s passed its gate on %s, and the verdict could not be kept: %v\n", p.ID, c.Module, c.Node, err)
}
}
}
// failCarried puts back every build the gate carried that it found wanting, on the machines that were
// sent it, once each.
func failCarried(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, except string) {
var notes []string
if p.Note != "" {
notes = append(notes, p.Note)
}
done := map[string]bool{except: true}
for _, m := range g.Returned {
done[m] = true // put back at once when it broke
}
// **What passed on its own keeps its pass** (novox/hq ADR 0254, issue 318): healthy for the passes the
// gate asks while another module of the send failed, it is neither put back nor left unjudged — a build
// left without a verdict is one more move every other walk would wait for.
if kept := keepPassing(ctx, open, p, g, except); len(kept) > 0 {
notes = append(notes, "passed on their own and kept: "+strings.Join(kept, ", "))
for _, m := range kept {
done[m] = true
}
}
for _, c := range g.Carried {
if done[c.Module] || (len(g.Failing) > 0 && !slices.Contains(g.Failing, c.Module)) {
continue
}
done[c.Module] = true
machines, err := sentTheBuild(ctx, open, c.Module, c.To)
if err != nil || len(machines) == 0 {
machines = []string{c.Node}
}
state := &inventory.PlanModule{Build: c.Build, Previous: c.From, Commit: c.To}
// A module of the plan's tier sent in the same send (issue 281) keeps its own record of it.
if s := p.Modules[c.Module]; s != nil && except != "" && s.GatedBy == except && s.Build == c.Build {
state = s
}
p.Note = ""
gateFailed(ctx, open, p, c.Module, state, machines, g.Why)
notes = append(notes, p.Note)
}
p.State = inventory.PlanFailed
p.Note = strings.Join(notes, "; ")
}
// keepPassing keeps a pass as the verdict of every build the gate carried that passed on its own when the
// send failed (ADR 0254), except the plan's own module; answers the modules kept.
func keepPassing(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, except string) []string {
var kept []string
seen := map[string]bool{}
for _, c := range g.Carried {
if c.Module == except || c.Build == "" || seen[c.Build] || !slices.Contains(g.Passing, c.Module) {
continue
}
seen[c.Build] = true
why := passedAloneWhy(g, c.Module)
if err := open.inventory.RecordGate(ctx, inventory.GateVerdict{Build: c.Build, Module: c.Module, Commit: c.To,
Previous: c.From, Plan: p.ID, Machines: []string{c.Node}, Verdict: inventory.GatePassed, Why: why,
Component: coreComponent(c.Module), JudgingFrom: g.Since}); err != nil {
fmt.Printf("%s: %s passed its gate on %s on its own, and the verdict could not be kept: %v\n", p.ID, c.Module,
c.Node, err)
continue
}
if !slices.Contains(kept, c.Module) {
kept = append(kept, c.Module)
}
}
return kept
}
// putBackBroken puts back, at once, every module a gate found broken and has not put back yet (issue 318
// review): a witness that reverted a core component, or a machine that refused or failed its send, is not
// left registered at the build that broke for as long as the modules beside it take to be judged — a resend
// meanwhile would declare it again. The send goes on judging the others to their own verdicts; its own
// verdict is still failed. lead and leadState are the module a plan's gate is kept on, when it is a plan's.
// Answers whether anything was put back.
func putBackBroken(ctx context.Context, open *stores, p *inventory.Plan, g *inventory.PlanGate, lead string,
leadState *inventory.PlanModule) bool {
var todo []string
for _, m := range g.Broken {
if !slices.Contains(g.Returned, m) {
todo = append(todo, m)
}
}
if len(todo) == 0 || g.Verdict != "" {
return false
}
stateWas, noteWas := p.State, p.Note
batched, back := batchingRollbacks(ctx)
var said []string
for _, m := range todo {
machines := g.Machines
var state *inventory.PlanModule
if m == lead && leadState != nil {
judged := *leadState
judged.Gate = nil // its own record of the failure; the send's gate goes on judging
state = &judged
} else {
i := slices.IndexFunc(g.Carried, func(c inventory.CarriedMove) bool { return c.Module == m })
if i < 0 {
continue
}
c := g.Carried[i]
state = &inventory.PlanModule{Build: c.Build, Previous: c.From, Commit: c.To}
if sent, err := sentTheBuild(ctx, open, m, c.To); err == nil && len(sent) > 0 {
machines = sent
} else {
machines = []string{c.Node}
}
}
// Counted as put back only once there is something to put back (a carried move or the plan's own).
g.Returned = append(g.Returned, m)
p.Note = ""
gateFailed(batched, open, p, m, state, machines, g.BrokenWhy)
if m == lead && leadState != nil {
leadState.Why = "put back at once, its send still judged: " + g.BrokenWhy
}
said = append(said, m)
}
sendRollbacks(ctx, open, p, back)
p.State = stateWas
p.Note = noteWas
if len(said) > 0 {
p.Note = fmt.Sprintf("put back at once: %s (%s); the rest of the send judged to their own verdicts",
strings.Join(said, ", "), g.BrokenWhy)
fmt.Printf("%s: %s\n", p.ID, p.Note)
}
return true
}
// passedAloneWhy is the verdict of a module that passed on its own while its send failed, with its own wait.
func passedAloneWhy(g *inventory.PlanGate, module string) string {
why := fmt.Sprintf("healthy %d times on its own while the send failed: %s", g.Healthy[module], g.Why)
if w := g.Waits[module]; w != "" {
why += waitsPrefix + w
}
return why
}
// sentTheBuild is every machine running a module that was last sent this build of it.
func sentTheBuild(ctx context.Context, open *stores, module, commit string) ([]string, error) {
running, err := open.inventory.Running(ctx, module)
if err != nil {
return nil, err
}
var out []string
for _, n := range running {
sent, known, err := open.inventory.SentBuilds(ctx, n)
if err != nil {
return nil, err
}
if known && sameCommit(sent[module], commit) {
out = append(out, n)
}
}
return out, nil
}
// releaseRepository is what a release plan says it is for, where a merge's says its repository.
const releaseRepository = "the builds waiting for a gate"
// releaseHeard is the machines a release plan may send: heard within their heartbeat's bound. A machine
// away is left, not judged against a bound it cannot meet. A variable so a test says who is heard.
var releaseHeard = func(ctx context.Context, open *stores) (map[string]bool, error) {
if d := doctorFrom; d != nil && d.watchdogs != nil {
return heardMachines(d), nil
}
reports, err := open.inventory.LastReports(ctx)
if err != nil {
return nil, err
}
out := map[string]bool{}
for _, r := range reports {
if r.At != nil && time.Since(*r.At) < 15*time.Minute {
out[r.Node] = true
}
}
return out, nil
}
// backlogNow is what the newest look found waiting, for `upgrade backlog` and the gate's probe.
var backlogNow struct {
held string
waiting map[string][]inventory.CarriedMove
}
// waitingMoves is every machine's moves no gate has seen and no started plan walks.
func waitingMoves(ctx context.Context, open *stores, all bool) (map[string][]inventory.CarriedMove, error) {
f, err := readMoveFacts(ctx, open.inventory)
if err != nil {
return nil, err
}
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return nil, err
}
out := map[string][]inventory.CarriedMove{}
for _, n := range nodes {
moves, err := machineMoves(ctx, open, f, n.Name, all)
if err != nil {
return nil, err
}
for _, mv := range moves {
if f.walkedBy(mv.Module, n.Name) == "" {
out[n.Name] = append(out[n.Name], mv)
}
}
}
return out, nil
}
// releaseBacklog opens a release plan when builds wait for a gate and none is open; not after a release
// plan failed, until a person releases one (by). Called with the plans held.
func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Plan, error) {
inv := open.inventory
plans, err := inv.OpenPlans(ctx)
if err != nil {
return nil, err
}
for _, p := range plans {
if p.Release != nil {
if by != "" {
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
}
return nil, nil
}
}
waiting, err := waitingMoves(ctx, open, false)
if err != nil {
return nil, err
}
backlogNow.waiting, backlogNow.held = waiting, ""
if len(waiting) == 0 {
return nil, nil
}
// Opened by what no gate has seen; it walks every machine where anything of the release waits — a
// build that passed on the first machine still goes to the next one by this plan, judged there too.
if waiting, err = waitingMoves(ctx, open, true); err != nil {
return nil, err
}
if by == "" {
recent, err := inv.RecentPlans(ctx, 50)
if err != nil {
return nil, err
}
for _, p := range recent {
if p.Release == nil {
continue
}
if p.State == inventory.PlanFailed {
backlogNow.held = fmt.Sprintf("%s failed (%s); what waits is released again by a person", p.ID, p.Note)
return nil, nil
}
break
}
}
heard, err := releaseHeard(ctx, open)
if err != nil {
return nil, err
}
controllers, err := inv.Running(ctx, catalogue.ControllerSeatName)
if err != nil {
return nil, err
}
var order, last []string
modules := map[string]bool{}
for node, moves := range waiting {
if !heard[node] {
continue
}
for _, mv := range moves {
modules[mv.Module] = true
}
if slices.Contains(controllers, node) {
last = append(last, node)
} else {
order = append(order, node)
}
}
if len(order)+len(last) == 0 {
return nil, nil
}
sort.Strings(order)
sort.Strings(last)
order = append(order, last...)
names := make([]string, 0, len(modules))
for m := range modules {
names = append(names, m)
}
sort.Strings(names)
now := time.Now().UTC()
p := inventory.Plan{ID: fmt.Sprintf("release-%d", now.UnixNano()), Repository: releaseRepository,
Created: now, State: inventory.PlanRolling, Tiers: [][]string{names}, Modules: map[string]*inventory.PlanModule{},
Release: &inventory.PlanRelease{Order: order, By: by},
Note: fmt.Sprintf("%d build(s) wait for a gate on %s; one machine at a time, each judged", len(names),
strings.Join(order, ", "))}
if err := inv.SavePlan(ctx, &p); err != nil {
return nil, err
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return &p, nil
}
// advanceRelease takes one step of a release plan: the next machine sent everything waiting there under a
// gate, or the machine being judged judged once more; the plan done when every machine is.
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
r := p.Release
now := time.Now().UTC()
if r.Gate == nil {
heard, err := releaseHeard(ctx, open)
if err != nil {
return false, err
}
for r.Next < len(r.Order) {
node := r.Order[r.Next]
if !heard[node] {
r.Skipped = append(r.Skipped, node)
r.Next++
continue
}
moves, sent, err := gatedSend(ctx, open, node, nil)
if errors.Is(err, errWalkedElsewhere) {
note := fmt.Sprintf("waiting before %s: %v", node, err)
changed := p.Note != note
p.Note = note
return changed, nil
}
if err != nil {
return false, fmt.Errorf("sending %s what waits there: %w", node, err)
}
if len(moves) == 0 {
r.Done = append(r.Done, node)
r.Next++
continue
}
r.Gate = &inventory.PlanGate{Machines: sent, Since: &now, Carried: moves}
p.Note = fmt.Sprintf("sent %s %d build(s) that waited for a gate; judging them there", node, len(moves))
if said := recreationsSaid(moves); said != "" {
p.Note += "; " + said
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
p.Note = fmt.Sprintf("released on %s", orNone(strings.Join(r.Done, ", ")))
if len(r.Skipped) > 0 {
p.Note += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
}
return true, nil
}
g := r.Gate
verdict, err := judgeMoves(ctx, open, g, nil, now)
if err != nil {
return false, err
}
switch verdict {
case "":
if putBackBroken(ctx, open, p, g, "", nil) {
return true, nil
}
note := fmt.Sprintf("judging %s: %s", strings.Join(g.Machines, ", "), gateLine(g))
changed := p.Note != note
p.Note = note
return changed, nil
case inventory.GatePassed:
passCarried(ctx, open, p, g, "")
r.Done = append(r.Done, firstOf(g.Machines))
r.Next++
r.Gate = nil
return true, nil
}
p.Note = ""
batched, back := batchingRollbacks(ctx)
failCarried(batched, open, p, g, "")
sendRollbacks(ctx, open, p, back)
p.Note = fmt.Sprintf("failed its gate on %s: %s — %s", strings.Join(g.Machines, ", "), g.Why, p.Note)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
// backlogObservation is what the gate's probe says of a release held after a failure.
func backlogObservation() []conditions.Observation {
if backlogNow.held == "" || len(backlogNow.waiting) == 0 {
return nil
}
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)
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)}
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`.
func backlogCommand(ctx context.Context, sub string, args []string) error {
set := flag.NewFlagSet("upgrade "+sub, flag.ContinueOnError)
why := addHandActFlags(set)
if rest, err := parseAround(set, args); err != nil {
return err
} else if len(rest) > 0 {
return errors.New("upgrade backlog | upgrade release-backlog --why <text>")
}
if sub == "release-backlog" {
if err := why.require("upgrade release-backlog"); err != nil {
return err
}
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if sub == "release-backlog" {
release, err := open.inventory.HoldPlans(ctx, true)
if err != nil {
return err
}
defer release()
why.record(ctx, "upgrade release-backlog", nil)
p, err := releaseBacklog(ctx, open, link.Caller())
if err != nil {
return err
}
if p == nil {
fmt.Println("nothing waits for a gate on any machine heard from: nothing to release")
return nil
}
fmt.Printf("%s releases it; `plans %s` says where it is\n", p.ID, p.ID)
return nil
}
waiting, err := waitingMoves(ctx, open, false)
if err != nil {
return err
}
if len(waiting) == 0 {
fmt.Println("no build waits for a gate on any machine")
return nil
}
nodes := make([]string, 0, len(waiting))
for n := range waiting {
nodes = append(nodes, n)
}
sort.Strings(nodes)
for _, n := range nodes {
fmt.Printf("%s: %d build(s) wait for a gate\n", n, len(waiting[n]))
for _, mv := range waiting[n] {
fmt.Printf(" %-28s %s → %s\n", mv.Module, short(mv.From), short(mv.To))
}
}
return nil
}
// sayRecreations says, on each move a send carries, what it does to the module's containers (novox/hq
// ADR 0245): the build the machine ran against the one it is sent, container by container. Left unsaid
// for a move whose earlier build is not in the records.
func sayRecreations(ctx context.Context, open *stores, moves []inventory.CarriedMove) {
if len(moves) == 0 {
return
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return
}
for i, mv := range moves {
to, held := shelf[mv.Module]
if !held || mv.From == "" {
continue
}
from, found, err := open.inventory.ManifestAt(ctx, mv.Module, mv.From)
if err != nil || !found {
continue
}
moves[i].Recreates = catalogue.Recreates(from, to).Say(mv.Module)
}
}
// sayWhatAPushRecreates says, per machine a push is about to send, every module the send moves and what
// the move recreates (novox/hq ADR 0245) — the same words a gated send keeps on its plan. A person's push
// carries every module's new build, whatever its policy and whether or not a gate saw it, so it is the
// send that most needs to say so: on 2026-10-07 a `push` of one machine moved mail to a new build and
// recreated one of its containers with a new image, and the answer said nothing about it. A machine whose
// last send's builds are not known is not said, and neither is a failure to read: the push goes on.
func sayWhatAPushRecreates(ctx context.Context, open *stores, sending []readyNode, w io.Writer) {
if len(sending) == 0 {
return
}
f, err := readMoveFacts(ctx, open.inventory)
if err != nil {
return
}
for _, r := range sending {
sent, known, err := open.inventory.SentBuilds(ctx, r.node)
if err != nil || !known {
continue
}
moves := pushMoves(f, r.node, r.declared.Builds, sent)
if len(moves) == 0 {
continue
}
sayRecreations(ctx, open, moves)
fmt.Fprintf(w, "%s moves %d module(s):\n", r.node, len(moves))
for _, mv := range moves {
said := mv.Recreates
if said == "" {
said = "recreates none of its containers, or what it ran is not in the records"
}
fmt.Fprintf(w, " %-28s %s → %s: %s\n", mv.Module, short(mv.From), short(mv.To), said)
}
}
}
// pushMoves is what a send carrying these builds moves on a machine last sent `sent`: every module it ran
// before at a build not identical to the one it is now sent. A module new to the machine is not a move.
func pushMoves(f moveFacts, node string, carries, sent map[string]string) []inventory.CarriedMove {
var out []inventory.CarriedMove
for m, to := range carries {
was, ran := sent[m]
if !ran || was == "" || to == "" || f.identical(m, was, to) {
continue
}
out = append(out, inventory.CarriedMove{Module: m, Node: node, From: was, To: to})
}
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
return out
}
// recreationsSaid is every recreation a send's moves say, joined: what a plan's note carries.
func recreationsSaid(moves []inventory.CarriedMove) string {
var said []string
for _, mv := range moves {
if mv.Recreates != "" {
said = append(said, fmt.Sprintf("on %s %s", mv.Node, mv.Recreates))
}
}
return strings.Join(said, "; ")
}