From 66cf82e7b6768e4acd0089b01d20089a47fc82ff Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 10:00:04 +0100 Subject: [PATCH 1/3] A catalogue change has every repository the node-engine's composer requires beside it (issue 1921, issue 1966) mesh-build-go's composer test builds the node-engine's composer from what lies beside, with nothing fetched; with mesh-sdk and the node-engine alone, its zero-version requirements on mesh-contracts (and mesh-delivery, mesh-networking) were looked up on the network and the group's fourth try failed. The quick fix, chosen: the catalogue's beside rule names them. The thorough fix, left: besideFor derives a built sibling's siblings from its go.mod's zero-version requirements. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../mesh-delivery-checker/internal/check/beside.go | 6 ++++-- .../mesh-delivery-checker/internal/check/pins_test.go | 11 ++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/mesh-delivery-checker/internal/check/beside.go b/modules/mesh-delivery-checker/internal/check/beside.go index 0b8a584d..dd4359d1 100644 --- a/modules/mesh-delivery-checker/internal/check/beside.go +++ b/modules/mesh-delivery-checker/internal/check/beside.go @@ -41,12 +41,14 @@ const ( // checks/deps). Which commit of each is besideCommit's rule. No repository's tests read the controller, which ended by its // unassignment (novox/hq ADR 0590, issue 1126), and its repository is emptied and archived (ADR 0593 §11), so it has no // siblings placed either. mesh-sdk reads mesh-contracts and mesh-delivery (its bootstrapped seats against their contracts files). The catalogue's tests read the node-engine since its composer command moved there (ADR 0593 -// §7): mesh-build-go builds it for a compose ask. The node-engine's read mesh-networking since route-proxy's proofs of a +// §7): mesh-build-go builds it for a compose ask, and with it every repository its composer requires at the zero version +// (mesh-contracts, mesh-delivery, mesh-networking; issue 1921: with mesh-sdk alone beside, that build looked them up on the +// network and failed). The node-engine's read mesh-networking since route-proxy's proofs of a // composition moved into them (ADR 0593). var besideRules = map[string][]string{ nodeEngineRepo: {catalogueRepository, SDKRepository, networkingRepository}, SDKRepository: {catalogueRepository, nodeEngineRepo, contractsRepository, deliveryRepository}, - catalogueRepository: {SDKRepository, nodeEngineRepo}, + catalogueRepository: {SDKRepository, nodeEngineRepo, contractsRepository, deliveryRepository, networkingRepository}, "novox/mesh-delivery": {SDKRepository, catalogueRepository}, } diff --git a/modules/mesh-delivery-checker/internal/check/pins_test.go b/modules/mesh-delivery-checker/internal/check/pins_test.go index a61b018f..cfef6a4b 100644 --- a/modules/mesh-delivery-checker/internal/check/pins_test.go +++ b/modules/mesh-delivery-checker/internal/check/pins_test.go @@ -199,7 +199,8 @@ func TestTheChangesOwnRepositoryIsBesideItAtItsTrunk(t *testing.T) { func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) { hostRuns := strings.Repeat("7", 40) b := newBench(t) - b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, "novox/mesh-controller": trunkA2, nodeEngineRepo: trunkA2}) + b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, "novox/mesh-controller": trunkA2, nodeEngineRepo: trunkA2, + contractsRepository: trunkA2, deliveryRepository: trunkA2, networkingRepository: trunkA2}) b.ex.Runs = runsAt{nodeEngineModule: hostRuns} out := b.judge(t, caseOf(t, pullOf(catalogueRepository, 12, head1))) // ADR 0548 §6: the outcome names the siblings read at their trunk's head or the commit the mesh runs, never the @@ -211,8 +212,12 @@ func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) { if c := got[catalogueRepository]; c.Commit != trunkA || c.Ref != "" { t.Fatalf("the catalogue beside its own change = %+v, want its trunk %s", c, trunkA) } - if _, ok := got[SDKRepository]; !ok { - t.Errorf("mesh-sdk is no longer beside a catalogue change: %v", got) + // Issue 1921: and every repository the node-engine's composer requires at the zero version, which mesh-build-go's + // composer test builds from what lies beside, with nothing fetched. + for _, repo := range []string{SDKRepository, contractsRepository, deliveryRepository, networkingRepository} { + if _, ok := got[repo]; !ok { + t.Errorf("%s is not beside a catalogue change: %v", repo, got) + } } if h := got[nodeEngineRepo]; h.Commit != hostRuns { t.Errorf("the node-engine beside a catalogue change = %+v, want the commit the mesh runs %s", h, hostRuns) From 29c525f48de53d1f26480c986fc7f937af8bd081 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 10:11:55 +0100 Subject: [PATCH 2/3] A sibling's own siblings come from its go.mod's zero-version requirements, so no beside rule can lack one (issue 1921) The third failure of the kind (tries 1, 3, 4): a sibling a test builds, the node-engine's composer, needs every repository it requires at the zero version beside it. besideFor now reads each sibling's root go.mod through the forge and places those repositories as any sibling is placed; the catalogue's rule names only what its tests read themselves. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../internal/check/beside.go | 96 ++++++++++++++++++- .../internal/check/pins_test.go | 23 ++++- 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-checker/internal/check/beside.go b/modules/mesh-delivery-checker/internal/check/beside.go index dd4359d1..559113f4 100644 --- a/modules/mesh-delivery-checker/internal/check/beside.go +++ b/modules/mesh-delivery-checker/internal/check/beside.go @@ -2,7 +2,13 @@ package check import ( "context" + "errors" + "fmt" + "slices" "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // BesideRef is a sibling repository a repository's tests read, placed beside the fetched tree as a plain directory (ADR @@ -42,13 +48,13 @@ const ( // unassignment (novox/hq ADR 0590, issue 1126), and its repository is emptied and archived (ADR 0593 §11), so it has no // siblings placed either. mesh-sdk reads mesh-contracts and mesh-delivery (its bootstrapped seats against their contracts files). The catalogue's tests read the node-engine since its composer command moved there (ADR 0593 // §7): mesh-build-go builds it for a compose ask, and with it every repository its composer requires at the zero version -// (mesh-contracts, mesh-delivery, mesh-networking; issue 1921: with mesh-sdk alone beside, that build looked them up on the -// network and failed). The node-engine's read mesh-networking since route-proxy's proofs of a -// composition moved into them (ADR 0593). +// (issue 1921: with mesh-sdk alone beside, that build looked them up on the network and failed): those are not named +// here but derived from each sibling's go.mod (zeroVersionSiblings), so no rule can lack one. The node-engine's read +// mesh-networking since route-proxy's proofs of a composition moved into them (ADR 0593). var besideRules = map[string][]string{ nodeEngineRepo: {catalogueRepository, SDKRepository, networkingRepository}, SDKRepository: {catalogueRepository, nodeEngineRepo, contractsRepository, deliveryRepository}, - catalogueRepository: {SDKRepository, nodeEngineRepo, contractsRepository, deliveryRepository, networkingRepository}, + catalogueRepository: {SDKRepository, nodeEngineRepo}, "novox/mesh-delivery": {SDKRepository, catalogueRepository}, } @@ -69,7 +75,7 @@ func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef, if p.Trunk != "" { out = append(out, BesideRef{Repository: p.Repository, Commit: p.Trunk}) } - for _, repo := range besideRules[p.Repository] { + place := func(repo string) { switch { case repo == nodeEngineRepo && c.member(repo) != nil: // A group's check judges each member against the group's heads (ADR 0573 §4, ADR 0482 §2), and the group's @@ -99,9 +105,89 @@ func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef, } } } + for _, repo := range besideRules[p.Repository] { + place(repo) + } + // A sibling a test builds needs every repository its go.mod requires at the zero version beside it too: the build + // resolves them from what lies beside, with nothing fetched (issue 1921). Its go.mod names them all, indirect ones + // included, so one level is enough. + derived, err := j.zeroVersionSiblings(ctx, p.Repository, out) + if err != nil { + return nil, err + } + for _, repo := range derived { + place(repo) + } return out, nil } +// zeroVersionSiblings are the repositories the root go.mod of each sibling in refs requires at the zero version that are +// not beside already, sorted; none where the checker reads no forge. A sibling with no root go.mod requires none; one the +// forge does not answer for is an error, never a sibling left out in silence. +func (j *Examiner) zeroVersionSiblings(ctx context.Context, own string, refs []BesideRef) ([]string, error) { + if j.Forge == nil { + return nil, nil + } + have := map[string]bool{own: true} + for _, r := range refs { + have[r.Repository] = true + } + var out []string + for _, r := range refs { + if r.Repository == own { + continue + } + text, err := j.Forge.File(ctx, r.Repository, "go.mod", r.Commit) + if errors.Is(err, ErrNotFound) { + continue + } + if err != nil { + return nil, fmt.Errorf("the go.mod of %s at %.12s, beside the change, cannot be read%s: %w", r.Repository, r.Commit, unheardWords(err), err) + } + for _, path := range zeroVersionRequires(text) { + repo, _, ok := delivery.PackageRepository(path) + if !ok || have[repo] { + continue + } + have[repo] = true + out = append(out, repo) + } + } + slices.Sort(out) + return out, nil +} + +// zeroVersionRequires are the module paths a go.mod's require lines (a block or single ones) name at the zero version. +func zeroVersionRequires(gomod string) []string { + var out []string + block := false + for _, line := range strings.Split(gomod, "\n") { + line, _, _ = strings.Cut(line, "//") + f := strings.Fields(line) + switch { + case len(f) == 0: + continue + case f[0] == "require" && len(f) > 1 && f[1] == "(": + block = true + continue + case block && f[0] == ")": + block = false + continue + case f[0] == "require": + f = f[1:] + case !block: + continue + } + if len(f) >= 2 && f[1] == zeroVersion { + out = append(out, f[0]) + } + } + return out +} + +// zeroVersion is the version a go.mod requires a mesh package at (ADR 0573 §4): the builder writes the version it builds. +const zeroVersion = "v0.0.0-00010101000000-000000000000" + func (c *Case) member(repository string) *Pull { for i := range c.Pulls { if c.Pulls[i].Repository == repository { diff --git a/modules/mesh-delivery-checker/internal/check/pins_test.go b/modules/mesh-delivery-checker/internal/check/pins_test.go index cfef6a4b..36705d16 100644 --- a/modules/mesh-delivery-checker/internal/check/pins_test.go +++ b/modules/mesh-delivery-checker/internal/check/pins_test.go @@ -1,6 +1,7 @@ package check import ( + "context" "maps" "slices" "strings" @@ -200,8 +201,15 @@ func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) { hostRuns := strings.Repeat("7", 40) b := newBench(t) b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, "novox/mesh-controller": trunkA2, nodeEngineRepo: trunkA2, - contractsRepository: trunkA2, deliveryRepository: trunkA2, networkingRepository: trunkA2}) + contractsRepository: trunkA2, deliveryRepository: trunkA2, networkingRepository: trunkA2, "novox/mesh-tools": trunkA2}) b.ex.Runs = runsAt{nodeEngineModule: hostRuns} + // The node-engine's go.mod at the commit the mesh runs: what it requires at the zero version is placed beside too, + // derived, not named in a rule (issue 1921); a version a build wrote, or a package outside the forge, is not. + b.ex.Forge = headFiles{nodeEngineRepo + " go.mod " + hostRuns: "module github.com/novox/mesh-host\n\ngo 1.26\n\nrequire (\n" + + "\tgit.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000\n" + + "\tgit.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect\n" + + "\tgit.novox.be/novox/mesh-tools/go v0.1.2\n\tgithub.com/nats-io/nats.go v1.40.0\n)\n\n" + + "require git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000\n"} out := b.judge(t, caseOf(t, pullOf(catalogueRepository, 12, head1))) // ADR 0548 §6: the outcome names the siblings read at their trunk's head or the commit the mesh runs, never the // repository it merges. @@ -219,6 +227,9 @@ func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) { t.Errorf("%s is not beside a catalogue change: %v", repo, got) } } + if _, ok := got["novox/mesh-tools"]; ok { + t.Errorf("mesh-tools, required at a version a build wrote, is beside a catalogue change: %v", got) + } if h := got[nodeEngineRepo]; h.Commit != hostRuns { t.Errorf("the node-engine beside a catalogue change = %+v, want the commit the mesh runs %s", h, hostRuns) } @@ -257,3 +268,13 @@ func TestAMeshLabHeadGetsACheck(t *testing.T) { t.Fatalf("mesh-lab was not fetched: %+v", fetched) } } + +// headFiles are the forge's files at a ref, by " ". +type headFiles map[string]string + +func (h headFiles) File(_ context.Context, repository, path, ref string) (string, error) { + if text, ok := h[repository+" "+path+" "+ref]; ok { + return text, nil + } + return "", ErrNotFound +} From d58b717243129b4090c62b060137098b07ec7485 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 6 Nov 2026 10:33:49 +0100 Subject: [PATCH 3/3] A try's end is said on a context its own stop does not cancel (issue 1921) The end a run writes comes back on the key's watch, where Put stops the run's context and leaves the saying to the run, so the end was never said and the indexer kept the try's candidate. The group try's unit test now echoes the end on the watch while the run says it. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../mesh-delivery-checker/internal/check/try.go | 5 ++++- .../internal/check/try_test.go | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-checker/internal/check/try.go b/modules/mesh-delivery-checker/internal/check/try.go index 8109a525..577f4d38 100644 --- a/modules/mesh-delivery-checker/internal/check/try.go +++ b/modules/mesh-delivery-checker/internal/check/try.go @@ -711,7 +711,10 @@ func (tr *Tryer) run(ctx context.Context, key string) { return } tr.logf("%s: %s", key, t.State) - tr.sendEnd(ctx, key) + // The end just written comes back on the key's watch, and Put then stops this run's context (it stops judging a try + // ended by hand) while this run says the end, which Put's own say leaves to it: the end is said on a context that + // stop does not cancel, or it is never said and the indexer keeps the candidate of the try's group. + tr.sendEnd(context.WithoutCancel(ctx), key) } // sendEnd says the end of an ended try whose start was said (Told) with the state it ended in, then clears Told in the try's diff --git a/modules/mesh-delivery-checker/internal/check/try_test.go b/modules/mesh-delivery-checker/internal/check/try_test.go index d0863d93..a5de63fb 100644 --- a/modules/mesh-delivery-checker/internal/check/try_test.go +++ b/modules/mesh-delivery-checker/internal/check/try_test.go @@ -188,7 +188,19 @@ func TestATryIsJudgedAsAGroupsCheckAndOwesTheForgeNothing(t *testing.T) { // ADR var waiting string cands.during = func() { waiting = readTry(t, kv, id).Waiting } // ADR 0639 §5: the stored try says what it waits for var said []string - tr := &Tryer{Tries: written, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error { + var tr *Tryer + echoed := false + tr = &Tryer{Tries: written, Judge: b.ex, Say: func(ctx context.Context, event string, e tryevent.Event) error { + if event == tryevent.Ended && !echoed { + // The end the run wrote comes back on the key's watch while the run says it: Put stops the run's context and + // leaves the saying to the run, whose say must not be stopped with it (issue 1921). + echoed = true + raw, _, _, _ := kv.Get(context.Background(), id) + tr.Put(context.Background(), id, raw) + } + if err := ctx.Err(); err != nil { + return err // a publish on a stopped context is never acknowledged + } said = append(said, fmt.Sprintf("%s %s %d %s", event, e.ID, len(e.Members), e.State)) return nil }}