An own-path batch is cut first and folds no waiting walk; a walk let go beside a started one starts once it ended; a late catalogue merge is not answered by a walk that waited for nobody (hq ADR 0276 review, tracker issue 377)
mesh/merge-gate pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without …
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
jochen
2026-10-10 14:33:10 +02:00
parent b9ceeace41
commit 04fcce2fcc
6 changed files with 430 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 {