diff --git a/cmd/mesh-controller/batches.go b/cmd/mesh-controller/batches.go index e947906f..3ba71b21 100644 --- a/cmd/mesh-controller/batches.go +++ b/cmd/mesh-controller/batches.go @@ -38,8 +38,9 @@ import ( // by that one. // // A merge on the controller's own path (a module whose walk waits for nobody's word) never shares a batch with -// one that waits for mesh-delivery's: each kind has a batch of its own, so no catalogue delivery skips its turn -// behind a controller merge (decided during the build, 2026-10-10). +// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds +// no waiting catalogue walk, and a catalogue walk let go while another walk runs starts once it ended — so no +// catalogue batch's walk starts without the word (decided during the build, 2026-10-10). // // The batch, its merges and their times are in the store (batched_merge, and the batch's own plan record in // the state `assembling` or `queued`), so a restarted controller resumes the window where it stood. @@ -181,7 +182,12 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T if _, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil || known { return notNow(err) } - event, err := json.Marshal(m) + var moves []string + for _, e := range touches { + moves = append(moves, e.Manifest.Module) + } + sort.Strings(moves) + event, err := json.Marshal(keptMerge{SourceMoved: m, Moves: moves}) if err != nil { return err } @@ -193,7 +199,7 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T // word** (decided during the build, 2026-10-10): batched together, the catalogue's deliveries would start // with the controller's and skip their turn. Each kind has a batch of its own. own := ownPath(touches) - if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own); err != nil { + if p, ok, err := answeredByALaterMerge(ctx, inv, heard, touches, own, deliverySeatHeld(entries)); err != nil { return notNow(err) } else if ok { heard.Plan = p.ID @@ -251,7 +257,7 @@ func owedLate(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, // later merge, which contains it. A failed or stopped walk answers nothing more, and a walk folded into // another is followed to that one. False when none does: the merge joins the next batch. func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard inventory.BatchedMerge, - moves []inventory.Entry, own bool) (inventory.Plan, bool, error) { + moves []inventory.Entry, own, held bool) (inventory.Plan, bool, error) { later, err := inv.LaterMergesOf(ctx, heard.Repository, heard.Branch, heard.Merged) if err != nil || len(later) == 0 { return inventory.Plan{}, false, err @@ -268,7 +274,11 @@ func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard return p, true, nil } case p.Open() || p.State == inventory.PlanDone: - if buildsEvery(p, moves) { + // A walk that waited for nobody's word is not a catalogue merge's to be answered by while the delivery + // seat has a holder: its delivery would skip its turn (decided during the build, 2026-10-10). With no + // holder on record nothing waits, and any walk that built it answers. + waited := p.Delivery != nil && p.Delivery.Awaits != "" + if buildsEvery(p, moves) && (own || waited || !held) { return p, true, nil } } @@ -431,7 +441,7 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven if repo == "" { repo = m.Repository } - n := inventory.PlanMerge{Repository: repo, Commit: m.Commit} + n := namedOf(m, repo) if l := latest[k]; l.Commit != m.Commit { n.Carried = l.Commit } @@ -443,15 +453,34 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven return commits, named } +// keptMerge is a merge as the record keeps it: the forge's announcement, and the modules it moved when it was +// heard. The announcement reads back as a SourceMoved alone, which ignores the rest. +type keptMerge struct { + link.SourceMoved + Moves []string `json:"moves,omitempty"` +} + +// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none. +func announcedOf(m inventory.BatchedMerge) keptMerge { + var k keptMerge + _ = json.Unmarshal(m.Event, &k) + return k +} + // spelledOf is a kept merge's repository as the forge spelled it; empty when its announcement does not say. func spelledOf(m inventory.BatchedMerge) string { - var e link.SourceMoved - if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" { + if e := announcedOf(m); e.Owner != "" { return e.Owner + "/" + e.Repo } return "" } +// namedOf is one merge as a walk or batch names it: its commit, and its pull request and moves as announced. +func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge { + k := announcedOf(m) + return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves} +} + // laterMerge says a was merged after b: by the forge's merge time, then by when each was heard. func laterMerge(a, b inventory.BatchedMerge) bool { if !a.Merged.Equal(b.Merged) { @@ -480,10 +509,11 @@ func nameMerges(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan // The walk's own commits stand: a merge heard late is carried by the one of its repository's branch. var named []inventory.PlanMerge for _, m := range merges { - n := inventory.PlanMerge{Repository: m.Repository, Commit: m.Commit} + repo := m.Repository if e := spelledOf(m); e != "" { - n.Repository = e + repo = e } + n := namedOf(m, repo) if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit { n.Carried = c } @@ -579,18 +609,18 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error { if err := keepAll(""); err != nil { return err } - // The oldest batch whose window closed is cut; one of each kind at most is open (theOpenBatch). + // The oldest batch whose window closed is cut, an own-path one before a catalogue one (it goes first, and + // the catalogue batch queues behind it rather than being cut and overtaken); one of each kind at most is + // open (theOpenBatch). + sort.SliceStable(batches, func(i, j int) bool { return batches[i].OwnPath() && !batches[j].OwnPath() }) for i := range batches { b := &batches[i] if !windowClosed(*b, now) { continue } if b.OwnPath() { - // A walk on the controller's own path folds no walk waiting for its word: those merges are batched - // again, behind it (decided during the build, 2026-10-10). - if err := rebatchWaiting(ctx, inv, waiting, b.ID, now); err != nil { - return err - } + // A walk on the controller's own path folds no walk waiting for its word (decided during the build, + // 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce). waiting = nil } if err := cutBatch(ctx, open, b, waiting, now); err != nil { @@ -608,46 +638,6 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error { return nil } -// rebatchWaiting puts the merges of the walks waiting for their word into the open batch of their kind — a new -// one when none is — queued behind the walk about to be cut, and closes the walks as taken over by that batch: -// the batch keeps its id when it is cut, so the delivery's owner follows them to the walk that answers them. -func rebatchWaiting(ctx context.Context, inv *inventory.Inventory, waiting []inventory.Plan, behind string, now time.Time) error { - for i := range waiting { - w := waiting[i] - merges, err := inv.MergesOf(ctx, w.ID) - if err != nil { - return err - } - if len(merges) == 0 { - continue // nothing of the record's: left waiting - } - batch, err := theOpenBatch(ctx, inv, merges[0], now, false) - if err != nil { - return err - } - for _, m := range merges { - if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, batch.ID, m.Alone); err != nil { - return err - } - } - if err := keepBatch(ctx, inv, &batch, now, behind); err != nil { - return err - } - w.State = inventory.PlanSuperseded - if w.Delivery == nil { - w.Delivery = &inventory.PlanDelivery{} - } - w.Delivery.TakenOverBy = batch.ID - w.Note = fmt.Sprintf("superseded at tier %d by %s before it started: a walk on the controller's own path "+ - "(%s) goes first, and that batch answers its merges after it", w.Tier, batch.ID, behind) - if err := inv.SavePlan(ctx, &w); err != nil { - return err - } - fmt.Printf(" %s is %s\n", w.ID, w.Note) - } - return nil -} - // cutBatch makes a closed batch one walk, folding in the walks that wait for their word: it keeps its id, and // the folded walks name it as the walk that took them over. func cutBatch(ctx context.Context, open *stores, batch *inventory.Plan, waiting []inventory.Plan, now time.Time) error { @@ -754,6 +744,9 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [ } } } + // **Whether it waits for its delivery's word** is read from what its merges moved (novox/hq ADR 0239), never + // from what a folded walk carried along, which holds dependents too. + awaits := awaitsFor(entries, moved) held := map[string]bool{} for _, e := range entries { held[e.Manifest.Module] = true @@ -773,9 +766,7 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [ plan.Commits = commits newest := newestCommit(commits) plan.Repository, plan.Branch, plan.Commit, plan.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged - // **Whether it waits for its delivery's word** (novox/hq ADR 0239): while the mesh-delivery seat has a - // holder on record, a walk that moves no module on the controller's own path is opened and waits. - plan.Delivery = awaitsFor(entries, moved) + plan.Delivery = awaits if plan.Delivery == nil { plan.Delivery = &inventory.PlanDelivery{} } @@ -1088,6 +1079,77 @@ func groupedWords(b inventory.Plan) string { return strings.Join(out, ", ") } +// batchLines is a batch's grouped list as `plans` prints it under its line (asked by the operator, 2026-10-10): +// one line per repository, its pull request and title, the earlier merges it answers, and what its merges move. +func batchLines(b inventory.Plan) []string { + var out []string + for _, c := range b.Carried() { + var head *inventory.PlanMerge + var answers []string + moves := map[string]bool{} + if b.Delivery != nil { + for i := range b.Delivery.Merges { + m := &b.Delivery.Merges[i] + if !strings.EqualFold(m.Repository, c.Repository) { + continue + } + for _, n := range m.Moves { + moves[n] = true + } + switch { + case m.Commit == c.Commit: + head = m + case m.Carried == c.Commit: + answers = append(answers, pullWords(*m)) + } + } + } + line := repoName(c.Repository) + " " + short(c.Commit) + if head != nil && head.Number > 0 { + line = repoName(c.Repository) + " " + pullWords(*head) + } + if len(answers) > 0 { + line += " (answers " + strings.Join(answers, ", ") + ")" + } + if len(moves) > 0 { + line += " · " + strings.Join(sortedKeysOf(boolsToStrings(moves)), ", ") + } + out = append(out, line) + } + return out +} + +// pullWords is a merge as its pull request: "#175 a tap shows its outcome", the title cut at fifty runes; the +// commit where the announcement named no pull request. +func pullWords(m inventory.PlanMerge) string { + if m.Number == 0 { + return short(m.Commit) + } + s := fmt.Sprintf("#%d", m.Number) + if t := cutTitle(m.Title, 50); t != "" { + s += " " + t + } + return s +} + +// cutTitle is a title cut at n runes, with an ellipsis where it was cut. +func cutTitle(title string, n int) string { + r := []rune(strings.TrimSpace(title)) + if len(r) <= n { + return string(r) + } + return strings.TrimSpace(string(r[:n-1])) + "…" +} + +// boolsToStrings is a set's members, for sortedKeysOf. +func boolsToStrings(set map[string]bool) map[string]string { + out := make(map[string]string, len(set)) + for k := range set { + out[k] = "" + } + return out +} + // secondsWords is a short wait as a person reads it. func secondsWords(d time.Duration) string { if d < 2*time.Minute { diff --git a/cmd/mesh-controller/batches_test.go b/cmd/mesh-controller/batches_test.go index 38af562a..c7cada52 100644 --- a/cmd/mesh-controller/batches_test.go +++ b/cmd/mesh-controller/batches_test.go @@ -136,7 +136,7 @@ func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) { } 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.Equal(w.Delivery.Merges, want) { + 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" { @@ -229,17 +229,28 @@ func TestTheAssemblingBatchIsShown(t *testing.T) { inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) } t.Cleanup(func() { inventory.PlanSaved = was }) now := time.Now().UTC() - hear(t, open, catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)), now.Add(-19*time.Second)) - hear(t, open, catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)), now.Add(-time.Second)) + 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, repoMerge("one", "a6bc0931one", now), now) out := captured(t, func() error { return plansCommand(t.Context(), nil) }) - first := strings.SplitN(out, "\n", 2)[0] + 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) @@ -364,7 +375,7 @@ func TestALateMergeIsAnsweredByTheWalkOfTheLaterOne(t *testing.T) { 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 || - got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: "553b7191early", + !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) } @@ -404,7 +415,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) { ws, _ = walks(t, open) alone := ws[0] if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 || - alone.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", Commit: middle.Commit}) { + !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", @@ -425,7 +436,7 @@ func TestAFailedWalkWalksItsEarlierMergesAlone(t *testing.T) { 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 || done.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/mesh-catalog", + 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) } @@ -565,7 +576,7 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) { } 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 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one", + 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) } @@ -589,10 +600,11 @@ func TestALateMergeOfAOneModuleRepositoryIsNamed(t *testing.T) { } } -// One walk at a time holds against the delivery's word too: a waiting walk is not let go while another started. -func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) { +// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the +// word and starts once that one ended, asking nothing before. +func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) { open := windowed(t) - asksWithPaths(t) + asked := asksWithPaths(t) ctx := t.Context() hear(t, open, repoMerge("one", "c1", t0), t0) cutAt(t, open, t0.Add(2*time.Minute)) @@ -603,9 +615,23 @@ func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) { 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 || - !strings.Contains(err.Error(), started[0].ID) { - t.Fatalf("a waiting walk was let go beside %s: %v", started[0].ID, 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) + } + 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) } } @@ -660,7 +686,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) { t.Fatal(err) } got, _ := open.inventory.PlanByID(ctx, w.ID) - if len(got.Delivery.Merges) != 2 || got.Delivery.Merges[0] != (inventory.PlanMerge{Repository: "novox/one", + if len(got.Delivery.Merges) != 2 || !sameMerge(got.Delivery.Merges[0], inventory.PlanMerge{Repository: "novox/one", Commit: "c1", Carried: "c2"}) { t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v", got.Delivery.Merges) @@ -706,50 +732,29 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) { 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))) } - // The catalogue batch, the older, is cut first and waits for its word; the controller's batch is cut next: - // the waiting walk is batched again behind it, not folded into it. + // 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, _ := walks(t, open) - if len(ws) != 1 || !ws[0].Waiting() { - t.Fatalf("the catalogue batch was not cut into a waiting walk: %+v", ws) - } - catalogueWalk := ws[0] - cutAt(t, open, t0.Add(2*time.Minute+5*time.Second)) - ws, bs = walks(t, open) - var ownWalk inventory.Plan - for _, w := range ws { - if w.Open() { - ownWalk = w - } - } - if ownWalk.ID == "" || ownWalk.Waiting() || ownWalk.CommitOf("novox/mesh-controller") != "k1" { - t.Fatalf("the controller's batch was not cut into a started walk: %+v", ws) + 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) } } - 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) - } + 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) } - folded, _ := open.inventory.PlanByID(ctx, catalogueWalk.ID) - if folded.State != inventory.PlanSuperseded || len(bs) != 1 || folded.Delivery.TakenOverBy != bs[0].ID || - bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() || - len(bs[0].Delivery.Merges) != 2 { - t.Fatalf("the waiting catalogue walk is %s (taken over by %q); batches %+v", folded.State, - folded.Delivery.TakenOverBy, bs) - } - // The controller's walk done, the catalogue batch is cut and waits for its word with both merges. 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 != folded.Delivery.TakenOverBy || !ws[0].Waiting() || len(ws[0].Delivery.Merges) != 2 { + 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"} { @@ -757,4 +762,127 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) { 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) + 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) + } } diff --git a/cmd/mesh-controller/delivery.go b/cmd/mesh-controller/delivery.go index e4a017fb..33b5bbfc 100644 --- a/cmd/mesh-controller/delivery.go +++ b/cmd/mesh-controller/delivery.go @@ -103,16 +103,8 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i return p, fmt.Errorf("%s was let go by %s at %s already", p.ID, p.Delivery.By, p.Delivery.Go.Local().Format("15:04:05")) } - // **One walk at a time** (novox/hq ADR 0276): a walk that started is open, so this one waits for its end. - open, err := inv.OpenPlans(ctx) - if err != nil { - return p, err - } - for _, q := range open { - if q.ID != p.ID && q.Release == nil && !q.Waiting() { - return p, fmt.Errorf("%s is open (%s): one walk at a time — %s is let go once it ended", q.ID, q.Named(), p.ID) - } - } + // **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is + // started (advanceOnce), so the delivery's owner says it once and is not refused. now := time.Now().UTC() p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why p.Note = "let go by " + by + "; its first tier is asked next" diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 6b238e9d..6717cf11 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -446,6 +446,44 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string, advanceHeld(ctx, open) } +// startedBeside is the id of a started walk open beside this one — a merge's walk past its wait, not the +// backlog's — or empty: one walk at a time (novox/hq ADR 0276). +func startedBeside(ctx context.Context, inv *inventory.Inventory, id string) (string, error) { + plans, err := inv.OpenPlans(ctx) + if err != nil { + return "", err + } + for _, q := range plans { + if q.ID == id || q.Release != nil || q.Waiting() { + continue + } + // Started: a tier asked, or on its way (let go, or waiting for nobody) before this one. + if q.Tier > 0 || askedAny(q) || q.Created.Before(createdOf(ctx, inv, id)) { + return q.ID, nil + } + } + return "", nil +} + +// askedAny says a plan asked any module. +func askedAny(p inventory.Plan) bool { + for _, s := range p.Modules { + if s != nil && s.State != "" { + return true + } + } + return false +} + +// createdOf is when a plan was made; zero when it cannot be read. +func createdOf(ctx context.Context, inv *inventory.Inventory, id string) time.Time { + p, err := inv.PlanByID(ctx, id) + if err != nil { + return time.Time{} + } + return p.Created +} + // advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built // and whose gates are applied gives way to the next; the last tier done is the plan done. Called // after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes. @@ -578,6 +616,18 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan, } } if unasked == len(tier) { + // **One walk at a time** (novox/hq ADR 0276): a walk about to ask its first tier while another started + // walk is open waits for that one to end, let go or not, and says so. + if p.Tier == 0 { + if behind, err := startedBeside(ctx, inv, p.ID); err != nil { + return false, err + } else if behind != "" { + note := fmt.Sprintf("let go; starts once %s ended — one walk at a time", behind) + changed := p.Note != note + p.Note = note + return changed, nil + } + } if err := askTier(ctx, inv, p); err != nil { return false, err } @@ -1181,27 +1231,34 @@ func inTierSince(p inventory.Plan) time.Time { // planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan // waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is // the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition -// said on the line (novox/hq issue 296). +// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn. func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string { + return planLineOn(p, now, pause, bound, nil) +} + +// planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each +// module; nil names no machine. +func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string { + name := planHeadline(p, running) where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers)) switch p.State { case inventory.PlanAssembling, inventory.PlanQueued: // A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left. return batchWords(p, now) case inventory.PlanDone: - return fmt.Sprintf("%s done, %d tier(s)", p.Named(), len(p.Tiers)) + return fmt.Sprintf("%s · done, %d tier(s)", name, len(p.Tiers)) case inventory.PlanFailed: - return fmt.Sprintf("%s FAILED at %s: %s", p.Named(), where, p.Note) + return fmt.Sprintf("%s · FAILED at %s: %s", name, where, p.Note) case inventory.PlanSuperseded: - return fmt.Sprintf("%s %s", p.Named(), p.Note) + return fmt.Sprintf("%s · %s", name, p.Note) } since := now.Sub(inTierSince(p)).Round(time.Second) if p.Waiting() { // Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239). - return fmt.Sprintf("%s %s, %s, for %s", p.Named(), where, waitingNote(p), since) + return fmt.Sprintf("%s · %s, %s, for %s", name, where, waitingNote(p), since) } if waiting, paused := pausedWaiting(p, pause, now); paused { - return fmt.Sprintf("%s %s, %s", p.Named(), where, waiting) + return fmt.Sprintf("%s · %s, %s", name, where, waiting) } late := "" if since > bound { @@ -1211,7 +1268,53 @@ func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.D if p.State == inventory.PlanRolling { what = p.Note } - return fmt.Sprintf("%s %s, %s for %s%s", p.Named(), where, what, since, late) + return fmt.Sprintf("%s · %s, %s for %s%s", name, where, what, since, late) +} + +// planHeadline names a plan as a person knows it (asked by the operator, 2026-10-10: a plan called by its +// repository alone said nothing of what it delivers): each repository as its pull request and title, what the +// plan moves, and the machines running that — "mesh-catalog #175 a tap shows its outcome · messenger, +// telegram → novox". A record naming no pull request is named by its commit, as before; one naming no moves +// says none; running nil names no machine. +func planHeadline(p inventory.Plan, running func(string) []string) string { + var repos []string + moves := map[string]bool{} + for _, c := range p.Carried() { + seg := repoName(c.Repository) + " " + short(c.Commit) + if p.Delivery != nil { + for _, m := range p.Delivery.Merges { + if !strings.EqualFold(m.Repository, c.Repository) { + continue + } + for _, n := range m.Moves { + moves[n] = true + } + if m.Commit == c.Commit && m.Number > 0 { + seg = repoName(c.Repository) + " " + pullWords(m) + } + } + } + repos = append(repos, seg) + } + out := strings.Join(repos, " + ") + if len(moves) == 0 { + return out + } + names := sortedKeysOf(boolsToStrings(moves)) + out += " · " + strings.Join(names, ", ") + if running == nil { + return out + } + nodes := map[string]bool{} + for _, n := range names { + for _, node := range running(n) { + nodes[node] = true + } + } + if len(nodes) > 0 { + out += " → " + strings.Join(sortedKeysOf(boolsToStrings(nodes)), ", ") + } + return out } // planFailedBuild marks the module a failed build was for when the result names no module: by the @@ -1353,8 +1456,11 @@ func plansCommand(ctx context.Context, args []string) error { return err } bounds := readTierBounds(ctx, inv, now) - fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}), - bounds.of(p.Repository))) + fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}), + bounds.of(p.Repository), func(module string) []string { + on, _ := inv.Running(ctx, module) + return on + })) if r := p.Release; r != nil { // A release plan's walk (ADR 0236): machines done, the one judged, those to come. fmt.Printf(" machines in order: %s; done: %s; skipped: %s\n", strings.Join(r.Order, ", "), @@ -1490,14 +1596,22 @@ func plansCommand(ctx context.Context, args []string) error { } for _, b := range batches { fmt.Printf("%-28s %s\n", b.ID, batchWords(b, now)) + // One line per repository: its pull request, what it answers, what it moves. + for _, line := range batchLines(b) { + fmt.Printf("%-28s %s\n", "", line) + } } pause := buildSeatPause(ctx, inv, plans) bounds := readTierBounds(ctx, inv, now) + running := func(module string) []string { + on, _ := inv.Running(ctx, module) + return on + } for _, p := range plans { if p.Batch() { continue } - fmt.Printf("%-28s %s\n", p.ID, planLineWith(p, now, pause, bounds.of(p.Repository))) + fmt.Printf("%-28s %s\n", p.ID, planLineOn(p, now, pause, bounds.of(p.Repository), running)) } return nil } diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index 56045490..44bfca4e 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -71,6 +71,11 @@ type PlanMerge struct { Repository string `json:"repository"` Commit string `json:"commit"` Carried string `json:"carried,omitempty"` + // Number and Title are the merge's pull request as the forge announced it, and Moves the modules the merge + // moved when it was heard: what `plans` names a walk by. Empty where the announcement said none. + Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` + Moves []string `json:"moves,omitempty"` } // PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge diff --git a/internal/link/events.go b/internal/link/events.go index f5cfec8c..eab227d9 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -210,9 +210,11 @@ type SourceMoved struct { // stands: a directory is a module only when the merge changed its manifest. ModuleDirsSaid bool `json:"module_dirs_said,omitempty"` - // Number is the pull request's, and Body its description (novox/hq ADR 0276): a delivery group's - // `after:` lines, read when its members are merged into one batch and their order becomes the walk's. + // Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery + // group's `after:` lines, read when its members are merged into one batch and their order becomes the + // walk's, and the words `plans` names a walk by. Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` }