From a7e51b416bc1aea0286fae168b70787b589916f9 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 13:25:18 +0100 Subject: [PATCH 1/3] checker_status reads the node-engine at its running commit, as the checker's decision does, so it stops listing every candidate as waiting for a trunk move (issue 568) --- .../cmd/mesh-delivery-checker-tools/main.go | 20 ++++++++++ .../internal/check/checker.go | 19 +++++++--- .../internal/check/rank_test.go | 37 +++++++++++++++++++ .../internal/check/read.go | 7 +++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index 4d5aa37..5972f6f 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -53,6 +53,25 @@ func read(s stdio.KeptState) check.StateRead { }} } +// runsOf says which commit the mesh runs of a module from the indexer's modules state, as the checker's own record of it does +// (the commit the module's record was computed at); "" when the record is not readable. +type runsOf struct{ modules check.StateRead } + +func (r runsOf) CommitOf(module string) string { + if r.modules.Get == nil { + return "" + } + raw, err := r.modules.Get(module) + if err != nil || len(raw) == 0 { + return "" + } + m, err := delivery.DecodeModule(raw) + if err != nil { + return "" + } + return m.Commit +} + // stateStore is a state the tools write: the tries. type stateStore struct { check.StateRead @@ -197,6 +216,7 @@ func main() { StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), TurnState: read(stdio.State("mesh-delivery-gate.turns"))} + r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it tries := stdio.State(check.TriesState) source := stdio.State("mesh-delivery-gate.source") trunks := func() (map[string]string, error) { diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index e168211..aad5c53 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -272,13 +272,20 @@ func (ch *Checker) stale(v delivery.Verdict, cs *Case) bool { // besideNow are the commits a sibling is read at now, by repository: its trunk head, and for the node-engine the commit the // mesh runs when the indexer names one, as besideFor places them. func (ch *Checker) besideNow() map[string]string { - now := ch.heads() - if ch.Runs != nil { - if c := ch.Runs.CommitOf(nodeEngineModule); c != "" { - if now == nil { - now = map[string]string{} + return besideAt(ch.heads(), ch.Runs) +} + +// besideAt places the commits a sibling is read at: the trunk heads, with the node-engine at the commit the mesh runs when +// runs names one (ADR 0548 §6). The checker's decision and the status read the same commits through it. +func besideAt(now map[string]string, runs interface{ CommitOf(module string) string }) map[string]string { + if runs != nil { + if c := runs.CommitOf(nodeEngineModule); c != "" { + out := make(map[string]string, len(now)+1) + for r, h := range now { + out[r] = h } - now[nodeEngineRepo] = c + out[nodeEngineRepo] = c + return out } } return now diff --git a/modules/mesh-delivery-checker/internal/check/rank_test.go b/modules/mesh-delivery-checker/internal/check/rank_test.go index 801fe8e..e436dd4 100644 --- a/modules/mesh-delivery-checker/internal/check/rank_test.go +++ b/modules/mesh-delivery-checker/internal/check/rank_test.go @@ -75,3 +75,40 @@ func TestCheckerStatusNamesTheWaitingChecksByKind(t *testing.T) { // 1897/AC4 t.Errorf("waiting = %v, want %v", st.Waiting, want) } } + +// 568 note 21: a verdict that read the node-engine at its running commit is not waiting for a trunk move while that commit +// is still the one the mesh runs, though the node-engine's trunk has moved on (ADR 0548 §6). +func TestCheckerStatusReadsTheNodeEngineAtItsRunningCommit(t *testing.T) { // 568/21 + cand := candidateOf(t, group(1), pullOf(repoA, 1, head1)) + ckey, _ := delivery.CandidatesKey(group(1)) + vkey := keyOf(t, group(1), cand) + dc, _ := delivery.DecodeCandidate(cand) + judged, err := delivery.EncodeVerdict(delivery.Verdict{Group: group(1), Heads: dc.HeadsHash, Trunk: map[string]string{repoA: trunkA}, Beside: map[string]string{nodeEngineRepo: "run111"}, State: delivery.VerdictPass, Levels: passing().Levels}) + if err != nil { + t.Fatal(err) + } + heads := func() (map[string]string, error) { + return map[string]string{repoA: trunkA, nodeEngineRepo: "trunk222"}, nil + } + for _, row := range []struct { + name string + runs runsAt + want int + }{ + {"the running commit the verdict read", runsAt{nodeEngineModule: "run111"}, 0}, + {"the running commit moved", runsAt{nodeEngineModule: "run999"}, 1}, + {"no running commit known: the trunk's", nil, 1}, + } { + rd := Reader{CandidateState: fakeState{ckey: cand}.read(), VerdictState: fakeState{vkey: judged}.read(), Trunks: heads} + if row.runs != nil { + rd.Runs = row.runs + } + st, err := rd.Status() + if err != nil { + t.Fatal(err) + } + if got := len(st.Waiting[KindTrunk]); got != row.want { + t.Errorf("%s: waiting for a trunk move = %v, want %d", row.name, st.Waiting[KindTrunk], row.want) + } + } +} diff --git a/modules/mesh-delivery-checker/internal/check/read.go b/modules/mesh-delivery-checker/internal/check/read.go index 11be7c4..ed7eaec 100644 --- a/modules/mesh-delivery-checker/internal/check/read.go +++ b/modules/mesh-delivery-checker/internal/check/read.go @@ -41,7 +41,10 @@ type Reader struct { Trunks func() (map[string]string, error) // TurnState is the gate's merge turns (ADR 0548 §3); without it no check is named as held by a turn. TurnState StateRead - Now func() time.Time + // Runs says which commit the mesh runs of a module (the indexer's modules), as the checker's decision reads it: the + // node-engine a verdict names is compared at its running commit, not at the trunk's (ADR 0548 §6); nil knows none. + Runs interface{ CommitOf(module string) string } + Now func() time.Time } // VerdictRead is a verdict as stored with the full report of the status of each pull request it judged (Report), when the @@ -489,7 +492,7 @@ func (r Reader) Status() (Status, error) { case IsNodesFailure(v): st.Waiting[KindNodes] = append(st.Waiting[KindNodes], c.Group) case heads != nil: - if cs := CaseOf(c, heads); cs != nil && (!sameTrunk(v.Trunk, cs.Trunk) || besideMoved(v.Beside, heads)) { + if cs := CaseOf(c, heads); cs != nil && (!sameTrunk(v.Trunk, cs.Trunk) || besideMoved(v.Beside, besideAt(heads, r.Runs))) { st.Waiting[KindTrunk] = append(st.Waiting[KindTrunk], c.Group) if why := heldBy(turns, c, heads); why != "" { st.HeldByTurn[c.Group] = why From b4a6a3470509a8d623c418d0e45b3d234210ca4c Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 13:26:18 +0100 Subject: [PATCH 2/3] mesh-grants: a user list held back after take-over raises the warning mesh-grants.user-list-held and clears it when the list advances, so the hold is not only in a tool's output (issue 1945) --- modules/mesh-grants/grants_test.go | 14 ++++++++ modules/mesh-grants/internal/grants/part.go | 3 ++ modules/mesh-grants/internal/grants/sealed.go | 35 ++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 369943e..eb98079 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -961,6 +961,17 @@ func TestGrants(t *testing.T) { if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a read of this pass failed") { t.Errorf("a pass past a failed read = %+v", res.Sealed) } + // While the hold lasts the warning stands, said once however many passes find it; it names why. + p.Compose() + nHeld := 0 + for _, c := range said { + if c.Key == grants.HeldCondition && c.State == asks.StateRaised && c.Severity == asks.SeverityWarning && strings.Contains(c.Summary, "a read of this pass failed") { + nHeld++ + } + } + if nHeld != 1 { + t.Errorf("the held list raised the warning %d times, want once: %+v", nHeld, said) + } b.refused[grants.ReadAccounts] = false if now, _, _ := p.Sealed(); now.Digest != g.Digest || now.Object != g.Object { t.Errorf("the list in force moved past a failed read: %s, was %s", now.Digest, g.Digest) @@ -990,6 +1001,9 @@ func TestGrants(t *testing.T) { if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest != g.Digest }() { t.Errorf("the list in force moved with every read and hash back") } + if last := said[len(said)-1]; last.Key != grants.HeldCondition || last.State != asks.StateCleared { + t.Errorf("the held warning is not cleared once nothing is held: %+v", last) + } } }) } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 348c9f7..29850f5 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -143,6 +143,9 @@ type Part struct { made creations // askSeen is the revision of the last ask to compose (KeyAsked) this part acted on. askSeen uint64 + // heldSaid is the reason of the held-list condition last said (empty: none raised); holdMu guards it. + holdMu sync.Mutex + heldSaid string } // Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index f960094..7244d0e 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -80,7 +81,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string key = in.Loaded.Key } digest := delivery.UserListDigest(list) - why := "" + why, held := "", "" switch { case p.Objects == nil: // The tools: the process writes; the follow alone may change here, from a tool's view of the switch, and is not. @@ -94,6 +95,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). why = p.holdsBack(in, leftOut, bus) + held = why case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { @@ -109,6 +111,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string } next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode } + p.sayHeld(held) if next.Digest == digest { next.Users, next.LeftOut = users, leftOut } @@ -143,6 +146,36 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string "left_out": len(next.LeftOut), "follow": next.Follow, "sealed_to": next.Node}) } +// sayHeld raises the warning condition mesh-grants.user-list-held while the list in force is kept (why, which words the pass's +// `held: ...`), and clears it on the first pass that holds nothing back: the list advanced, or the reason went. The refusal +// condition's way (sayRefused): said once per reason, again when the reason changes, and tried again when a say failed. +func (p *Part) sayHeld(why string) { + if p.Say == nil { + return + } + p.holdMu.Lock() + defer p.holdMu.Unlock() + if why == p.heldSaid { + return + } + c := asks.Condition{Key: HeldCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} + if why == "" { + c.State = asks.StateCleared + } else { + reason := strings.TrimPrefix(why, "held: ") + c.Severity, c.State = asks.SeverityWarning, asks.StateRaised + c.Summary, c.Headline, c.Needs = "the user list is held: "+reason, "The user list in force is held back", "a look at why" + c.Explanation = asks.NeedsYou + "a look at why. The bus node keeps following the list written before, not the one composed now: " + reason + c.Resolved = "a pass finds nothing to hold back and the list advances" + } + if p.Say(c) == nil { + p.heldSaid = why + } +} + +// HeldCondition is the key of the warning raised while a list in force is held back after take-over. +const HeldCondition = "mesh-grants.user-list-held" + func sameSealed(a, b delivery.GrantsUserList) bool { a.At, b.At = b.At, a.At x, _ := json.Marshal(a) From ce600df98afcd50bb0ed868b5d1fa4555f6c9554 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 13:37:31 +0100 Subject: [PATCH 3/3] Say the held-list state on a part's first pass, so a warning raised before a restart is cleared --- modules/mesh-grants/grants_test.go | 9 +++++++++ modules/mesh-grants/internal/grants/part.go | 9 ++++++--- modules/mesh-grants/internal/grants/sealed.go | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index eb98079..77842e3 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1004,6 +1004,15 @@ func TestGrants(t *testing.T) { if last := said[len(said)-1]; last.Key != grants.HeldCondition || last.State != asks.StateCleared { t.Errorf("the held warning is not cleared once nothing is held: %+v", last) } + // A part started while the warning stood (the said state is in memory only) clears it on its first pass. + var fresh []asks.Condition + fp := partOn(b) + fp.Objects = p.Objects + fp.Asker, fp.Say = fa, func(c asks.Condition) error { fresh = append(fresh, c); return nil } + fp.Compose() + if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared { + t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh) + } } }) } diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 29850f5..38457d7 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -143,9 +143,12 @@ type Part struct { made creations // askSeen is the revision of the last ask to compose (KeyAsked) this part acted on. askSeen uint64 - // heldSaid is the reason of the held-list condition last said (empty: none raised); holdMu guards it. - holdMu sync.Mutex - heldSaid string + // heldSaid is the reason of the held-list condition last said (empty: none raised); heldKnown is whether one was said + // since the start, so the first pass after a start always says its state (a condition raised before a restart is cleared + // by it). holdMu guards both. + holdMu sync.Mutex + heldSaid string + heldKnown bool } // Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 7244d0e..6a584cc 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -155,7 +155,7 @@ func (p *Part) sayHeld(why string) { } p.holdMu.Lock() defer p.holdMu.Unlock() - if why == p.heldSaid { + if p.heldKnown && why == p.heldSaid { return } c := asks.Condition{Key: HeldCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} @@ -169,7 +169,7 @@ func (p *Part) sayHeld(why string) { c.Resolved = "a pass finds nothing to hold back and the list advances" } if p.Say(c) == nil { - p.heldSaid = why + p.heldSaid, p.heldKnown = why, true } }