Merge pull request 'An own-path merge never shares a batch with a catalogue merge, and plans names a walk by what it moves (tracker issues 377, 378)' (#200) from fix/377-378-own-path-batches-and-named-plan-lines into main

This commit was merged in pull request #200.
This commit is contained in:
2026-10-10 13:14:37 +00:00
7 changed files with 456 additions and 127 deletions
+122 -60
View File
@@ -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 {
+187 -45
View File
@@ -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,37 @@ 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)
}
// 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)
}
}
@@ -660,7 +700,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 +746,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 +776,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)
}
}
+2 -10
View File
@@ -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"
+131 -10
View File
@@ -446,6 +446,47 @@ 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, created time.Time) (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(created) {
return q.ID, nil
}
}
return "", nil
}
// deferredNote begins the note of a walk deferred behind another.
const deferredNote = "let go; starts once "
// deferred says a walk has its word and has not started: let go while another walk was started, it waits for
// that one to end (startedBeside), and its note says so. Read as a wait, not as a tier running late: `plans`
// and `status` say its note, and S3 leaves it out. A let-go walk whose first ask failed carries that error as
// its note instead, and is watched as before.
func deferred(p inventory.Plan) bool {
return p.Open() && p.Release == nil && p.Tier == 0 && !askedAny(p) && p.Delivery != nil &&
p.Delivery.Awaits != "" && p.Delivery.Go != nil && strings.HasPrefix(p.Note, deferredNote)
}
// 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
}
// 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 +619,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, p.Created); err != nil {
return false, err
} else if behind != "" {
note := fmt.Sprintf("%s%s ended — one walk at a time", deferredNote, behind)
changed := p.Note != note
p.Note = note
return changed, nil
}
}
if err := askTier(ctx, inv, p); err != nil {
return false, err
}
@@ -1181,27 +1234,38 @@ 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 deferred(p) {
// Nor is waiting for the walk before it to end (one walk at a time, novox/hq ADR 0276).
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, 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 +1275,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 +1463,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 +1603,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
}
+5
View File
@@ -490,6 +490,11 @@ func gatherPlans(ctx context.Context, inv *inventory.Inventory, now time.Time, b
awaits: p.Delivery.Awaits, since: p.Created, modules: planModules(p), merges: p.Delivery.Merges})
continue
}
// A walk let go and waiting for the walk before it to end (ADR 0276) is no tier late either: the walk
// before it is the one S3 watches.
if deferred(p) {
continue
}
_, paused := pausedWaiting(p, pause, now)
bound := bounds.of(p.Repository)
out = append(out, planFacts{id: p.ID, repository: p.Repository, commit: p.Commit, tier: p.Tier,