package main import ( "bytes" "context" "encoding/json" "io" "os" "slices" "strings" "testing" "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a // window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge // was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings. func init() { mergeWindowDefault = 0 } // windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its // settings file says them, and // modules built from the catalogue (app, notes) and from three repositories of their own. func windowed(t *testing.T) *stores { t.Helper() open := aMesh(t) ctx := t.Context() inv := open.inventory settings := t.TempDir() + "/merge-window.json" if err := os.WriteFile(settings, []byte(`{"merge-window": "90s", "merge-window-at-most": "10m"}`), 0o600); err != nil { t.Fatal(err) } t.Setenv(mergeWindowFileVar, settings) noGroups(t) if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"}, inventory.Source{Repository: "novox/mesh-controller", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } for _, m := range []struct{ module, repository, path string }{ {"app", "novox/mesh-catalog", "modules/app"}, {"notes", "novox/mesh-catalog", "modules/notes"}, {"one", "novox/one", ""}, {"two", "novox/two", ""}, {"three", "novox/three", ""}, } { if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1"}, inventory.Source{Repository: m.repository, Seat: "git", Path: m.path, Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } } return open } // t0 is the moment a test's clock starts: merges are dated by it, and heard after it. var t0 = time.Now().UTC().Truncate(time.Second) // catalogueMerge is a merge of the catalogue changing one module's directory, made at a moment, from a pull // request's head branch of its own, as the forge announces every merge. Whether its head names a delivery group // with a member still to merge is mesh-delivery's word, which windowed stubs as no group at all (noGroups); a test // whose merges wait for the window as a group's says so (groupsWaiting). func catalogueMerge(commit, module string, made time.Time) link.SourceMoved { return link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Head: "pr/" + commit, Commit: commit, MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"modules/" + module + "/module.json"}, ModuleDirs: []string{"modules/" + module}, ModuleDirsSaid: true} } // repoMerge is a merge of a repository of one module's own, from a head branch of its own (catalogueMerge). func repoMerge(repo, commit string, made time.Time) link.SourceMoved { return link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Head: "pr/" + commit, Commit: commit, MergedAt: made.Format(time.RFC3339Nano), Paths: []string{"main.go"}} } // noGroups stubs mesh-delivery's word on groups for a test: no head names a group. func noGroups(t *testing.T) { t.Helper() was := deliveryGroupOf deliveryGroupOf = func(context.Context, bool, string) ([]groupMember, bool, error) { return nil, false, nil } t.Cleanup(func() { deliveryGroupOf = was }) } // groupsWaiting stubs mesh-delivery's word on groups for a test: each head named is a group with a member of another // repository whose pull request is open (novox/elsewhere, ready), so a merge from it waits for the window (ADR 0306 // decision 2); every other head names no group. func groupsWaiting(t *testing.T, heads ...string) { t.Helper() was := deliveryGroupOf deliveryGroupOf = func(_ context.Context, _ bool, head string) ([]groupMember, bool, error) { if slices.Contains(heads, head) { return []groupMember{{Repository: "novox/elsewhere", State: "ready"}}, true, nil } return nil, false, nil } t.Cleanup(func() { deliveryGroupOf = was }) } // everyHeadWaits stubs mesh-delivery's word on groups for a test whose merges each wait for the window as they did // before novox/hq ADR 0304: every head is a group with a member of another repository still to merge. func everyHeadWaits(t *testing.T) { t.Helper() was := deliveryGroupOf deliveryGroupOf = func(context.Context, bool, string) ([]groupMember, bool, error) { return []groupMember{{Repository: "novox/elsewhere", State: "ready"}}, true, nil } t.Cleanup(func() { deliveryGroupOf = was }) } // fromHead is a merge from a head branch a test names: merges from one head are members of one delivery group, // related by it (novox/hq ADR 0304 decision 1), so they share a batch whatever else they share. func fromHead(m link.SourceMoved, head string) link.SourceMoved { m.Head = head return m } // lone is a merge that names no head: known to be no delivery group's, it waits for no window unless it is // related to a batch assembling (ADR 0304 decision 1). func lone(m link.SourceMoved) link.SourceMoved { m.Head = "" return m } // hear is a merge heard at a moment. func hear(t *testing.T, open *stores, m link.SourceMoved, now time.Time) { t.Helper() if err := (following{open: open}).hearMerge(t.Context(), m, now); err != nil { t.Fatal(err) } } // cutAt is the cutter looking at a moment. func cutAt(t *testing.T, open *stores, now time.Time) { t.Helper() if err := cutBatchesHeld(t.Context(), open, now); err != nil { t.Fatal(err) } } // walks is every plan record that is a walk, newest first, and the batches not yet cut. func walks(t *testing.T, open *stores) (walks, batches []inventory.Plan) { t.Helper() recent, err := open.inventory.RecentPlans(t.Context(), 50) if err != nil { t.Fatal(err) } for _, p := range recent { if p.Batch() { batches = append(batches, p) } else { walks = append(walks, p) } } return walks, batches } // The replay of issue 362: two catalogue merges 18 seconds apart are one batch, cut once into one walk at the // later commit, which names both merges — the earlier carried by the later. func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) { open := windowed(t) everyHeadWaits(t) asked := asksWithPaths(t) claude := catalogueMerge("553b7191claude", "app", t0) dunst := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second)) hear(t, open, claude, t0.Add(time.Second)) hear(t, open, dunst, t0.Add(18*time.Second)) if ws, bs := walks(t, open); len(ws) != 0 || len(bs) != 1 { t.Fatalf("within the window: %d walk(s), %d batch(es)", len(ws), len(bs)) } cutAt(t, open, t0.Add(18*time.Second+89*time.Second)) if ws, _ := walks(t, open); len(ws) != 0 { t.Fatalf("cut before the window closed: %+v", ws) } cutAt(t, open, t0.Add(18*time.Second+90*time.Second)) ws, bs := walks(t, open) if len(ws) != 1 || len(bs) != 0 { t.Fatalf("after the window: %d walk(s), %d batch(es)", len(ws), len(bs)) } w := ws[0] if w.Commit != dunst.Commit || len(w.Commits) != 1 || w.Commits[0].Commit != dunst.Commit { t.Fatalf("the walk is at %s %+v, not the later commit", w.Commit, w.Commits) } for _, m := range []string{"app", "notes"} { if _, in := w.Modules[m]; !in { t.Fatalf("the walk does not build %s, which one of its merges moved: %v", m, w.Modules) } } want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit}, {Repository: "novox/mesh-catalog", Commit: dunst.Commit}} if w.Delivery == nil || !slices.EqualFunc(w.Delivery.Merges, want, sameMerge) { t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want) } if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" { t.Fatalf("the walk did not ask its modules at the branch, which holds the commit it carries: %v", *asked) } // Heard again, as the bus may hand it over twice: never a second merge, nor a second walk. hear(t, open, claude, t0.Add(5*time.Minute)) if ws, bs := walks(t, open); len(ws) != 1 || len(bs) != 0 { t.Fatalf("a merge heard twice made %d walk(s) and %d batch(es)", len(ws), len(bs)) } } // Merges of three repositories in one window are one walk, one commit for each. func TestThreeRepositoriesInOneWindowAreOneWalk(t *testing.T) { open := windowed(t) groupsWaiting(t, "feat/three") asksRecorded(t) for i, r := range []string{"one", "two", "three"} { hear(t, open, fromHead(repoMerge(r, "c-"+r, t0.Add(time.Duration(i)*20*time.Second)), "feat/three"), t0.Add(time.Duration(i)*20*time.Second+time.Second)) } cutAt(t, open, t0.Add(41*time.Second+90*time.Second)) ws, bs := walks(t, open) if len(ws) != 1 || len(bs) != 0 { t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs)) } w := ws[0] if len(w.Commits) != 3 || len(w.Delivery.Merges) != 3 { t.Fatalf("the walk carries %+v and answers %+v", w.Commits, w.Delivery.Merges) } for _, r := range []string{"one", "two", "three"} { if w.CommitOf("novox/"+r) != "c-"+r { t.Fatalf("the walk carries %s at %q", r, w.CommitOf("novox/"+r)) } if _, in := w.Modules[r]; !in { t.Fatalf("the walk does not build %s: %v", r, w.Modules) } } // Each module's ask is recorded at its own repository's commit. for _, r := range []string{"one", "two", "three"} { q, found, err := open.inventory.BuildRequestByID(t.Context(), w.Modules[r].Build) if err != nil || !found || q.Commit != "c-"+r { t.Fatalf("%s's build was not recorded at its own commit: %+v %v %v", r, q, found, err) } } } // Each merge restarts the window: a merge at second 80 keeps the batch open until second 170; the window // closes at most ten minutes after its first merge however busy. func TestAMergeRestartsTheWindowAndTheMaximumClosesIt(t *testing.T) { open := windowed(t) groupsWaiting(t, "feat/both", "feat/busy") asksWithPaths(t) hear(t, open, fromHead(repoMerge("one", "c1", t0), "feat/both"), t0) hear(t, open, fromHead(repoMerge("two", "c2", t0.Add(80*time.Second)), "feat/both"), t0.Add(80*time.Second)) cutAt(t, open, t0.Add(100*time.Second)) if ws, _ := walks(t, open); len(ws) != 0 { t.Fatal("the window closed 90 seconds after its first merge, not after its last") } cutAt(t, open, t0.Add(170*time.Second)) if ws, _ := walks(t, open); len(ws) != 1 || len(ws[0].Commits) != 2 { t.Fatalf("the window did not close 90 seconds after its last merge: %+v", ws) } busy := windowed(t) groupsWaiting(t, "feat/both", "feat/busy") asksWithPaths(t) var last time.Time for i := 0; i < 12; i++ { last = t0.Add(time.Duration(i) * time.Minute) repo := []string{"one", "two", "three"}[i%3] hear(t, busy, fromHead(repoMerge(repo, "c"+string(rune('a'+i)), last), "feat/busy"), last) if ws, _ := walks(t, busy); len(ws) > 0 { if i != 10 { t.Fatalf("a busy window closed at merge %d (minute %d), not at its maximum", i, i) } break } } ws, _ := walks(t, busy) if len(ws) != 1 { t.Fatalf("ten minutes of merges a minute apart were never cut: %d walk(s)", len(ws)) } } // `plans` lists the batch being assembled first, in the operator's words: how long is left, when at the latest, // what is grouped, and that the plan is not yet calculated. It says so on the bus as plan-moved, too. func TestTheAssemblingBatchIsShown(t *testing.T) { open := windowed(t) groupsWaiting(t, "pr/553b7191claude") asksWithPaths(t) var said []inventory.Plan was := inventory.PlanSaved inventory.PlanSaved = func(_ *inventory.Inventory, p inventory.Plan) { said = append(said, p) } t.Cleanup(func() { inventory.PlanSaved = was }) now := time.Now().UTC() claude := catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)) claude.Number, claude.Title = 174, "claude-code: an agent proposes a section" dunst := catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)) dunst.Number, dunst.Title = 175, "dunst: the font the operator chose" hear(t, open, claude, now.Add(-19*time.Second)) hear(t, open, dunst, now.Add(-time.Second)) hear(t, open, fromHead(repoMerge("one", "a6bc0931one", now), dunst.Head), now) out := captured(t, func() error { return plansCommand(t.Context(), nil) }) lines := strings.Split(out, "\n") first := lines[0] for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " + "(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} { if !strings.Contains(first, want) { t.Fatalf("plans' first line %q does not say %q", first, want) } } // Under it, one line per repository: the pull request, what it answers, what it moves. if len(lines) < 3 || strings.TrimSpace(lines[1]) != "mesh-catalog #175 dunst: the font the operator chose (answers "+ "#174 claude-code: an agent proposes a section) · app, notes" || strings.TrimSpace(lines[2]) != "one a6bc0931 · one" { t.Fatalf("the grouped list reads %q", lines[1:4]) } if len(said) == 0 || said[len(said)-1].State != inventory.PlanAssembling || said[len(said)-1].Delivery.Batch == nil || len(said[len(said)-1].Delivery.Merges) != 3 { t.Fatalf("the batch was not said as assembling with its merges: %+v", said) } } // captured is what a command printed. func captured(t *testing.T, run func() error) string { t.Helper() r, w, err := os.Pipe() if err != nil { t.Fatal(err) } was := os.Stdout os.Stdout = w runErr := run() os.Stdout = was _ = w.Close() var b bytes.Buffer _, _ = io.Copy(&b, r) if runErr != nil { t.Fatal(runErr) } return b.String() } // A merge heard during a walk it is related to (the same repository, issue 973) joins the next batch, which is // cut when the walk ends; the walk that started is never superseded (ADR 0276 decision 4, ADR 0407 decision 4). func TestAMergeDuringAWalkJoinsTheNextBatch(t *testing.T) { open := windowed(t) asksWithPaths(t) hear(t, open, repoMerge("one", "c1", t0), t0) cutAt(t, open, t0.Add(90*time.Second)) ws, _ := walks(t, open) if len(ws) != 1 || !ws[0].Open() { t.Fatalf("the first batch was not cut: %+v", ws) } first := ws[0] hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute)) cutAt(t, open, t0.Add(5*time.Minute)) ws, bs := walks(t, open) if len(ws) != 1 || len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != first.ID { t.Fatalf("the merge during the walk: %d walk(s), batches %+v", len(ws), bs) } if got, _ := open.inventory.PlanByID(t.Context(), first.ID); !got.Open() { t.Fatalf("the started walk was %s by the next merge", got.State) } if !strings.HasPrefix(batchWords(bs[0], t0.Add(5*time.Minute)), "queued behind "+first.ID) { t.Fatalf("a queued batch reads %q", batchWords(bs[0], t0.Add(5*time.Minute))) } first.State = inventory.PlanDone if err := open.inventory.SavePlan(t.Context(), &first); err != nil { t.Fatal(err) } cutAt(t, open, t0.Add(6*time.Minute)) ws, bs = walks(t, open) if len(ws) != 2 || len(bs) != 0 || ws[0].Commit != "c2" { t.Fatalf("the queued batch was not cut when the walk ended: %+v %+v", ws, bs) } } // A walk waiting for its delivery's word is folded into the next batch's walk, which answers its merges; it // names the walk that took it over. func TestAWaitingWalkIsFoldedIntoTheNextBatch(t *testing.T) { open := windowed(t) asksWithPaths(t) ctx := t.Context() if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1", Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}}, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil { t.Fatal(err) } hear(t, open, repoMerge("one", "c1", t0), t0) cutAt(t, open, t0.Add(90*time.Second)) ws, _ := walks(t, open) if len(ws) != 1 || !ws[0].Waiting() { t.Fatalf("the walk does not wait for its word: %+v", ws) } waiting := ws[0] hear(t, open, repoMerge("one", "c2", t0.Add(2*time.Minute)), t0.Add(2*time.Minute)) cutAt(t, open, t0.Add(2*time.Minute+90*time.Second)) ws, bs := walks(t, open) if len(bs) != 0 || len(ws) != 2 { t.Fatalf("%d walk(s), %d batch(es)", len(ws), len(bs)) } folded, err := open.inventory.PlanByID(ctx, waiting.ID) if err != nil { t.Fatal(err) } newer := ws[0] if folded.State != inventory.PlanSuperseded || folded.Delivery.TakenOverBy != newer.ID { t.Fatalf("the waiting walk is %s, taken over by %q", folded.State, folded.Delivery.TakenOverBy) } if len(newer.Delivery.Merges) != 2 || newer.CommitOf("novox/one") != "c2" { t.Fatalf("the newer walk answers %+v", newer.Delivery.Merges) } if _, in := newer.Modules["one"]; !in { t.Fatalf("what the folded walk was to build is not built: %v", newer.Modules) } } // A merge heard after a later merge of its branch was walked is answered by that walk when it builds all it // moves — named at once on a walk done — and joins the next batch when it does not. func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) { open := windowed(t) everyHeadWaits(t) asksWithPaths(t) ctx := t.Context() later := catalogueMerge("48bda475dunst", "notes", t0.Add(17*time.Second)) hear(t, open, later, t0.Add(18*time.Second)) cutAt(t, open, t0.Add(2*time.Minute)) ws, _ := walks(t, open) w := ws[0] w.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &w); err != nil { t.Fatal(err) } // Moves only notes, which the done walk built from the branch after it: answered there, at once. hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute)) got, _ := open.inventory.PlanByID(ctx, w.ID) if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early", Carried: later.Commit}) { t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges) } // Moves app, which that walk never built: the next batch walks it. hear(t, open, catalogueMerge("1111aaaaapp", "app", t0.Add(time.Second)), t0.Add(16*time.Minute)) _, bs := walks(t, open) if len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "1111aaaaapp" { t.Fatalf("a late merge moving what the walk never built did not join the next batch: %+v", bs) } } // A failed walk marks nothing delivered: its earlier merges are walked alone, on their own commit, the newest // first; the first delivered answers the older ones, and the search stops. func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) { open := windowed(t) everyHeadWaits(t) asked := asksWithPaths(t) ctx := t.Context() // Made before the cut marks their modules seen, as merges are: walked again, they are news all the same. oldest := catalogueMerge("aaaa0001", "app", t0.Add(-3*time.Minute)) middle := catalogueMerge("bbbb0002", "app", t0.Add(-3*time.Minute+10*time.Second)) newest := catalogueMerge("cccc0003", "notes", t0.Add(-3*time.Minute+20*time.Second)) for i, m := range []link.SourceMoved{oldest, middle, newest} { hear(t, open, m, t0.Add(time.Duration(i)*10*time.Second+time.Second)) } cutAt(t, open, t0.Add(2*time.Minute)) ws, _ := walks(t, open) failed := ws[0] failed.State, failed.Note = inventory.PlanFailed, "notes failed to build in tier 0" if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) } cutAt(t, open, t0.Add(3*time.Minute)) got, _ := open.inventory.PlanByID(ctx, failed.ID) if got.State != inventory.PlanFailed || len(got.Delivery.Merges) != 3 { t.Fatalf("the failed walk's record changed: %s %+v", got.State, got.Delivery.Merges) } ws, _ = walks(t, open) alone := ws[0] if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 || !sameMerge(alone.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) { t.Fatalf("the newest earlier merge was not walked alone on its own commit: %+v", alone) } if _, in := alone.Modules["app"]; !in || (*asked)[len(*asked)-1] != [3]string{"novox/mesh-catalog", "modules/app", middle.Commit} { t.Fatalf("the merge walked alone does not build app at its own commit: %v, asked %v", alone.Modules, *asked) } // Retried, the failed walk would name merges the search answers now. if _, err := retryPlan(ctx, open, failed.ID); err == nil || !strings.Contains(err.Error(), "walked alone") { t.Fatalf("a failed walk whose merges are searched was retried: %v", err) } alone.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &alone); err != nil { t.Fatal(err) } cutAt(t, open, t0.Add(4*time.Minute)) ws, bs := walks(t, open) if ws[0].ID != alone.ID || len(bs) != 0 { t.Fatalf("the search went on after a merge was delivered: %+v", ws[0]) } done, _ := open.inventory.PlanByID(ctx, alone.ID) if len(done.Delivery.Merges) != 2 || !sameMerge(done.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: oldest.Commit, Carried: middle.Commit}) { t.Fatalf("the oldest merge is not answered by the walk that delivered the one after it: %+v", done.Delivery.Merges) } // A stopped walk starts no search: a person ended it. hear(t, open, catalogueMerge("dddd0004", "app", t0.Add(5*time.Minute)), t0.Add(5*time.Minute)) hear(t, open, catalogueMerge("eeee0005", "notes", t0.Add(5*time.Minute+time.Second)), t0.Add(5*time.Minute+time.Second)) cutAt(t, open, t0.Add(8*time.Minute)) ws, _ = walks(t, open) if _, err := stopWalk(ctx, open.inventory, ws[0].ID, "mesh-delivery for jochen", "not now"); err != nil { t.Fatal(err) } cutAt(t, open, t0.Add(9*time.Minute)) if again, _ := walks(t, open); again[0].ID != ws[0].ID { t.Fatalf("a stopped walk's earlier merge was walked alone: %+v", again[0]) } } // A delivery group's order holds inside the walk (ADR 0249 unchanged): merges of two repositories from one head // branch name, the node-engine's before the controller's, are tiered so; without the group, one tier. func TestAGroupsOrderBecomesTiersInsideTheWalk(t *testing.T) { for _, grouped := range []bool{true, false} { open := windowed(t) everyHeadWaits(t) asksWithPaths(t) ctx := t.Context() for _, m := range []string{"mesh-host"} { if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"}, inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } } host := repoMerge("mesh-host", "h1", t0) ctl := repoMerge("mesh-controller", "k1", t0.Add(time.Second)) host.Head, ctl.Head = "feat/together", "feat/together" if !grouped { ctl.Head = "feat/alone" } hear(t, open, ctl, t0.Add(2*time.Second)) hear(t, open, host, t0.Add(3*time.Second)) cutAt(t, open, t0.Add(2*time.Minute)) ws, _ := walks(t, open) if len(ws) != 1 { t.Fatalf("%d walks", len(ws)) } tiers := ws[0].Tiers if grouped { if len(tiers) != 2 || !slices.Equal(tiers[0], []string{"mesh-host"}) || !slices.Equal(tiers[1], []string{"mesh-controller"}) { t.Fatalf("the group's order is not the walk's tiers: %v", tiers) } } else if len(tiers) != 1 { t.Fatalf("two merges of no group were ordered: %v", tiers) } } } // A restarted controller resumes the window where it stood: the batch, its merges and their times are in the // store, a merge handed over again is not doubled, and the batch is cut when its window closes. func TestABatchSurvivesARestart(t *testing.T) { open := windowed(t) groupsWaiting(t, "feat/both") asksWithPaths(t) hear(t, open, fromHead(repoMerge("one", "c1", t0), "feat/both"), t0) hear(t, open, fromHead(repoMerge("two", "c2", t0.Add(30*time.Second)), "feat/both"), t0.Add(30*time.Second)) // A new controller: nothing of the first one's but the store. again, err := openStores(t.Context()) if err != nil { t.Fatal(err) } t.Cleanup(again.Close) hear(t, again, fromHead(repoMerge("one", "c1", t0), "feat/both"), t0.Add(60*time.Second)) // the bus hands it over again cutAt(t, again, t0.Add(119*time.Second)) if ws, bs := walks(t, again); len(ws) != 0 || len(bs) != 1 || len(bs[0].Delivery.Merges) != 2 { t.Fatalf("the restarted controller's batch: %d walk(s), %+v", len(ws), bs) } cutAt(t, again, t0.Add(120*time.Second)) ws, bs := walks(t, again) if len(ws) != 1 || len(bs) != 0 || len(ws[0].Delivery.Merges) != 2 { t.Fatalf("the restarted controller did not cut the batch at its window: %+v %+v", ws, bs) } } // The window's settings reach the controller in its settings file, read at every look; one that says no // duration, or no file, is the default. func TestTheWindowIsTheControllersSetting(t *testing.T) { file := t.TempDir() + "/merge-window.json" t.Setenv(mergeWindowFileVar, file) for _, c := range []struct { content string window, atMost time.Duration }{ {`{"merge-window": "60s", "merge-window-at-most": "5m"}`, time.Minute, 5 * time.Minute}, {`{"merge-window": 30, "merge-window-at-most": ""}`, 30 * time.Second, mergeWindowAtMostDefault}, {`{"merge-window": "soon"}`, mergeWindowDefault, mergeWindowAtMostDefault}, } { if err := os.WriteFile(file, []byte(c.content), 0o600); err != nil { t.Fatal(err) } if w, m := mergeWindowOf(); w != c.window || m != c.atMost { t.Errorf("%s reads as %s and %s, want %s and %s", c.content, w, m, c.window, c.atMost) } } t.Setenv(mergeWindowFileVar, file+".gone") if w, m := mergeWindowOf(); w != mergeWindowDefault || m != mergeWindowAtMostDefault { t.Errorf("no file reads as %s and %s", w, m) } } // S16 names every merge a waiting walk answers, never one commit a supersession may have ended (issue 362). func TestAWaitingWalkNamesTheMergesItAnswers(t *testing.T) { f := calm(t0) f.waits = []waitFacts{{id: "plan-2", repository: "novox/mesh-catalog", commit: "48bda475dunst", awaits: "mesh-delivery", since: t0.Add(-31 * time.Minute), merges: []inventory.PlanMerge{ {Repository: "novox/mesh-catalog", Commit: "553b7191claude", Carried: "48bda475dunst"}, {Repository: "novox/mesh-catalog", Commit: "48bda475dunst"}}}} got := watchWaits(f) if len(got) != 1 || !strings.Contains(got[0].Summary, "novox/mesh-catalog@553b7191") || !strings.Contains(got[0].Summary, "novox/mesh-catalog@48bda475") { t.Fatalf("the waiting walk does not name both merges: %+v", got) } } // A merge heard after a later merge of its branch was cut is named by that walk however its modules read since // the cut, in a repository of one module (review of this change); a walk that never built what it moves names // nothing, and the merge joins the next batch. func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) { open := windowed(t) everyHeadWaits(t) asksWithPaths(t) ctx := t.Context() c2 := repoMerge("one", "c2", t0.Add(-50*time.Second)) hear(t, open, c2, t0) cutAt(t, open, t0.Add(2*time.Minute)) ws, _ := walks(t, open) w := ws[0] w.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &w); err != nil { t.Fatal(err) } hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute)) got, _ := open.inventory.PlanByID(ctx, w.ID) if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one", Commit: "c1", Carried: "c2"}) { t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges) } // A walk of two that built only two: a late merge of one is not its to name. hear(t, open, repoMerge("two", "d2", t0.Add(16*time.Minute)), t0.Add(16*time.Minute)) cutAt(t, open, t0.Add(18*time.Minute)) ws, _ = walks(t, open) w = ws[0] delete(w.Modules, "two") w.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &w); err != nil { t.Fatal(err) } hear(t, open, repoMerge("two", "d1", t0.Add(15*time.Minute)), t0.Add(20*time.Minute)) if got, _ := open.inventory.PlanByID(ctx, w.ID); len(got.Delivery.Merges) != 1 { t.Fatalf("a walk that never built what the late merge moves named it: %+v", got.Delivery.Merges) } if _, bs := walks(t, open); len(bs) != 1 || bs[0].Delivery.Merges[0].Commit != "d1" { t.Fatalf("the late merge did not join the next batch: %+v", bs) } } // A related walk waits for the delivery's word too: a waiting walk let go beside a started one it is related to // (the same repository, issue 973) takes the word and starts once that one ended, asking nothing before. func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) { open := windowed(t) asked := asksWithPaths(t) ctx := t.Context() hear(t, open, repoMerge("one", "c1", t0), t0) cutAt(t, open, t0.Add(2*time.Minute)) started, _ := walks(t, open) waiting := inventory.Plan{ID: "plan-waiting", Repository: "novox/one", Branch: "main", Commit: "d1", Created: t0, State: inventory.PlanBuilding, Tiers: [][]string{{"one"}}, Modules: map[string]*inventory.PlanModule{"one": {}}, Delivery: &inventory.PlanDelivery{Awaits: catalogue.DeliverySeat}} if err := open.inventory.SavePlan(ctx, &waiting); err != nil { t.Fatal(err) } if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err != nil { t.Fatalf("the word was refused beside %s: %v", started[0].ID, err) } before := len(*asked) advanceHeld(ctx, open) got, _ := open.inventory.PlanByID(ctx, waiting.ID) if len(*asked) != before || !strings.Contains(got.Note, "starts once "+started[0].ID) { t.Fatalf("a walk let go beside a started one asked (%d → %d) or does not say it waits: %q", before, len(*asked), got.Note) } // Read as a wait, an hour on: its note on the line, never LATE, and no tier of it late for S3. later := time.Now().Add(time.Hour) if line := planLine(got, later); !strings.Contains(line, "starts once "+started[0].ID) || strings.Contains(line, "LATE") { t.Fatalf("a deferred walk reads %q", line) } facts, _, err := gatherPlans(ctx, open.inventory, later, nil) if err != nil { t.Fatal(err) } for _, f := range facts { if f.id == got.ID { t.Fatalf("a deferred walk is watched as a tier running late: %+v", f) } } done := started[0] done.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &done); err != nil { t.Fatal(err) } advanceHeld(ctx, open) if len(*asked) != before+1 { t.Fatalf("the walk did not start once the started one ended: asked %v", *asked) } } // A file a merge of the batch removed and a later one brought back is not removed; one removed last is. func TestARemovedFileIsTheLastMergesWord(t *testing.T) { ev := func(commit string, paths, removed []string) inventory.BatchedMerge { m := link.SourceMoved{Owner: "novox", Repo: "one", Base: "main", Commit: commit, Paths: paths, Removed: removed} b, _ := json.Marshal(m) return inventory.BatchedMerge{Repository: "novox/one", Branch: "main", Commit: commit, Event: b, Merged: t0.Add(time.Duration(len(commit)) * time.Second)} } got := combinedMerges([]inventory.BatchedMerge{ ev("a", []string{"x/module.json", "y/module.json"}, []string{"x/module.json", "y/module.json"}), ev("bb", []string{"x/module.json"}, nil), ev("ccc", []string{"z.go"}, nil)}) if len(got) != 1 || got[0].Commit != "ccc" || !slices.Equal(got[0].Removed, []string{"y/module.json"}) || len(got[0].Paths) != 3 { t.Fatalf("combined as %+v", got) } } // A merge on the controller's own path never shares a batch with one that waits for mesh-delivery's word // (decided during the build, 2026-10-10): the two are batches of their own, cut one after the other; a walk on // the own path folds no waiting catalogue walk but batches its merges again behind it; the catalogue's walk // still waits for the word, so no catalogue delivery skips its turn. func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) { open := windowed(t) everyHeadWaits(t) asked := asksWithPaths(t) ctx := t.Context() if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-delivery", Version: "1", Claims: []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}}, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/mesh-delivery", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil { t.Fatal(err) } hear(t, open, catalogueMerge("aaaa0001", "app", t0), t0) hear(t, open, repoMerge("mesh-controller", "k1", t0.Add(10*time.Second)), t0.Add(10*time.Second)) hear(t, open, catalogueMerge("bbbb0002", "notes", t0.Add(20*time.Second)), t0.Add(20*time.Second)) _, bs := walks(t, open) var ownBatch, catalogueBatch inventory.Plan for _, b := range bs { if b.OwnPath() { ownBatch = b } else { catalogueBatch = b } } if len(bs) != 2 || ownBatch.ID == "" || catalogueBatch.ID == "" || len(catalogueBatch.Delivery.Merges) != 2 || len(ownBatch.Delivery.Merges) != 1 { t.Fatalf("a controller merge and two catalogue merges in one window: %+v", bs) } if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") { t.Fatalf("the own-path batch does not say so: %q", batchWords(ownBatch, t0.Add(30*time.Second))) } // Both windows closed, the controller's batch is cut first and starts; the catalogue batch queues behind it, // is cut when the controller's walk ended, and waits for its word with both merges. cutAt(t, open, t0.Add(2*time.Minute)) ws, bs := walks(t, open) if len(ws) != 1 || ws[0].Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k1" { t.Fatalf("the controller's batch was not cut first into a started walk: %+v", ws) } ownWalk := ws[0] for _, m := range []string{"app", "notes"} { if _, in := ownWalk.Modules[m]; in { t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m) } } if len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() { t.Fatalf("the catalogue batch does not queue behind the controller's walk: %+v", bs) } ownWalk.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil { t.Fatal(err) } cutAt(t, open, t0.Add(3*time.Minute)) ws, bs = walks(t, open) if len(bs) != 0 || ws[0].ID != catalogueBatch.ID || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 { t.Fatalf("the catalogue batch was not cut into a waiting walk once the controller's ended: %+v %+v", ws, bs) } for _, m := range []string{"app", "notes"} { if _, in := ws[0].Modules[m]; !in { t.Fatalf("the catalogue walk does not build %s: %v", m, ws[0].Modules) } } for _, a := range *asked { if a[1] == "modules/app" || a[1] == "modules/notes" { t.Fatalf("a catalogue module was asked without the word: %v", *asked) } } // A catalogue walk waiting for its word when an own-path batch is cut keeps waiting beside the own-path // walk, is not folded into it, and its word is taken meanwhile: it starts once the own-path walk ended. catalogueWalk := ws[0] hear(t, open, repoMerge("mesh-controller", "k2", t0.Add(4*time.Minute)), t0.Add(4*time.Minute)) cutAt(t, open, t0.Add(6*time.Minute)) ws, _ = walks(t, open) kept, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID) if !kept.Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k2" || ws[0].Waiting() { t.Fatalf("the waiting catalogue walk was not kept waiting beside the controller's walk: %s %+v", kept.State, ws) } if _, in := ws[0].Modules["app"]; in { t.Fatalf("the controller's walk folded the waiting catalogue walk in: %v", ws[0].Modules) } if _, err := letGo(ctx, open.inventory, catalogueWalk.ID, catalogue.DeliverySeat, "its turn"); err != nil { t.Fatal(err) } before := len(*asked) advanceHeld(ctx, open) if len(*asked) != before { t.Fatalf("the catalogue walk started beside the controller's: asked %v", (*asked)[before:]) } own2 := ws[0] own2.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &own2); err != nil { t.Fatal(err) } advanceHeld(ctx, open) if len(*asked) < before+1 || (*asked)[before][1] != "modules/app" { t.Fatalf("the catalogue walk did not start once the controller's ended: %v", (*asked)[before:]) } } // `plans` names a walk by what it moves (asked by the operator, 2026-10-10): the repository's pull request and // title, the modules it moves and the machines running them, then the state words; a record naming no pull // request is named by its commit, and a title is cut at fifty runes. func TestAPlanLineNamesWhatItMoves(t *testing.T) { now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC) p := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1", State: inventory.PlanBuilding, Tiers: [][]string{{"messenger", "telegram"}}, TierEntered: now.Add(-2 * time.Minute), Modules: map[string]*inventory.PlanModule{"messenger": {State: "asked"}, "telegram": {State: "asked"}}, Commits: []inventory.PlanCommit{{Repository: "novox/mesh-catalog", Branch: "main", Commit: "c1c1c1c1c1"}}, Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: "c1c1c1c1c1", Number: 175, Title: "a tap shows its outcome", Moves: []string{"telegram", "messenger"}}}}} running := func(module string) []string { if module == "messenger" || module == "telegram" { return []string{"novox"} } return nil } if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running), "mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want { t.Fatalf("the line reads %q, want %q", got, want) } if got := planLine(p, now); !strings.HasPrefix(got, "mesh-catalog #175 a tap shows its outcome · messenger, telegram · tier") { t.Fatalf("without the machines the line reads %q", got) } old := p old.Commits, old.Delivery = nil, nil if got := planLine(old, now); !strings.HasPrefix(got, "mesh-catalog c1c1c1c1 · tier 1 of 1") { t.Fatalf("a record naming no pull request reads %q", got) } long := p long.Delivery.Merges[0].Title = strings.Repeat("abcdefghij", 6) if got := planHeadline(long, nil); !strings.Contains(got, "#175 "+strings.Repeat("abcdefghij", 4)+"abcdefghi…") { t.Fatalf("a long title is not cut at fifty runes: %q", got) } } // sameMerge compares what a walk names of a merge: its repository, commit and the commit carrying it. func sameMerge(a, b inventory.PlanMerge) bool { return a.Repository == b.Repository && a.Commit == b.Commit && a.Carried == b.Carried } // A catalogue merge heard after a later merge of its branch was walked without the word (a merge that touched // the bus too, on the controller's own path) is not answered by that walk while the delivery seat has a holder: // its delivery would skip its turn. It joins the next catalogue batch. func TestALateCatalogueMergeIsNotAnsweredByAnOwnPathWalk(t *testing.T) { open := windowed(t) everyHeadWaits(t) asksWithPaths(t) ctx := t.Context() for _, m := range []struct { module string claims []catalogue.Claim }{ {"nats", nil}, {"mesh-delivery", []catalogue.Claim{{Name: catalogue.DeliverySeat, Scope: catalogue.ScopeMesh}}}, } { if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m.module, Version: "1", Claims: m.claims}, inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m.module, Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil { t.Fatal(err) } } if _, err := open.inventory.Assign(ctx, "anchor", "mesh-delivery"); err != nil { t.Fatal(err) } mixed := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "m1xed", MergedAt: t0.Format(time.RFC3339Nano), Paths: []string{"modules/nats/module.json", "modules/app/module.json"}, ModuleDirs: []string{"modules/nats", "modules/app"}, ModuleDirsSaid: true} hear(t, open, mixed, t0.Add(time.Second)) cutAt(t, open, t0.Add(2*time.Minute)) ws, _ := walks(t, open) if len(ws) != 1 || ws[0].Waiting() { t.Fatalf("the mixed merge's walk waited, or was not cut: %+v", ws) } done := ws[0] done.State = inventory.PlanDone if err := open.inventory.SavePlan(ctx, &done); err != nil { t.Fatal(err) } hear(t, open, catalogueMerge("ear1ier", "app", t0.Add(-30*time.Second)), t0.Add(3*time.Minute)) got, _ := open.inventory.PlanByID(ctx, done.ID) _, bs := walks(t, open) if len(got.Delivery.Merges) != 1 || len(bs) != 1 || bs[0].OwnPath() || bs[0].Delivery.Merges[0].Commit != "ear1ier" { t.Fatalf("the late catalogue merge was answered by the own-path walk (%+v) rather than the next catalogue batch (%+v)", got.Delivery.Merges, bs) } }