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)
This commit is contained in:
+122
-60
@@ -38,8 +38,9 @@ import (
|
|||||||
// by that one.
|
// 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
|
// 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
|
// one that waits for mesh-delivery's: each kind has a batch of its own, an own-path batch is cut first and folds
|
||||||
// behind a controller merge (decided during the build, 2026-10-10).
|
// 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 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.
|
// 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 {
|
if _, known, err := inv.MergeOf(ctx, repository, m.Commit); err != nil || known {
|
||||||
return notNow(err)
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// 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.
|
// with the controller's and skip their turn. Each kind has a batch of its own.
|
||||||
own := ownPath(touches)
|
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)
|
return notNow(err)
|
||||||
} else if ok {
|
} else if ok {
|
||||||
heard.Plan = p.ID
|
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
|
// 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.
|
// 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,
|
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)
|
later, err := inv.LaterMergesOf(ctx, heard.Repository, heard.Branch, heard.Merged)
|
||||||
if err != nil || len(later) == 0 {
|
if err != nil || len(later) == 0 {
|
||||||
return inventory.Plan{}, false, err
|
return inventory.Plan{}, false, err
|
||||||
@@ -268,7 +274,11 @@ func answeredByALaterMerge(ctx context.Context, inv *inventory.Inventory, heard
|
|||||||
return p, true, nil
|
return p, true, nil
|
||||||
}
|
}
|
||||||
case p.Open() || p.State == inventory.PlanDone:
|
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
|
return p, true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,7 +441,7 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
|||||||
if repo == "" {
|
if repo == "" {
|
||||||
repo = m.Repository
|
repo = m.Repository
|
||||||
}
|
}
|
||||||
n := inventory.PlanMerge{Repository: repo, Commit: m.Commit}
|
n := namedOf(m, repo)
|
||||||
if l := latest[k]; l.Commit != m.Commit {
|
if l := latest[k]; l.Commit != m.Commit {
|
||||||
n.Carried = l.Commit
|
n.Carried = l.Commit
|
||||||
}
|
}
|
||||||
@@ -443,15 +453,34 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
|||||||
return commits, named
|
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.
|
// 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 {
|
func spelledOf(m inventory.BatchedMerge) string {
|
||||||
var e link.SourceMoved
|
if e := announcedOf(m); e.Owner != "" {
|
||||||
if json.Unmarshal(m.Event, &e) == nil && e.Owner != "" {
|
|
||||||
return e.Owner + "/" + e.Repo
|
return e.Owner + "/" + e.Repo
|
||||||
}
|
}
|
||||||
return ""
|
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.
|
// 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 {
|
func laterMerge(a, b inventory.BatchedMerge) bool {
|
||||||
if !a.Merged.Equal(b.Merged) {
|
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.
|
// The walk's own commits stand: a merge heard late is carried by the one of its repository's branch.
|
||||||
var named []inventory.PlanMerge
|
var named []inventory.PlanMerge
|
||||||
for _, m := range merges {
|
for _, m := range merges {
|
||||||
n := inventory.PlanMerge{Repository: m.Repository, Commit: m.Commit}
|
repo := m.Repository
|
||||||
if e := spelledOf(m); e != "" {
|
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 {
|
if c := p.CommitOn(m.Repository, m.Branch); c != "" && c != m.Commit {
|
||||||
n.Carried = c
|
n.Carried = c
|
||||||
}
|
}
|
||||||
@@ -579,18 +609,18 @@ func cutBatchesHeld(ctx context.Context, open *stores, now time.Time) error {
|
|||||||
if err := keepAll(""); err != nil {
|
if err := keepAll(""); err != nil {
|
||||||
return err
|
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 {
|
for i := range batches {
|
||||||
b := &batches[i]
|
b := &batches[i]
|
||||||
if !windowClosed(*b, now) {
|
if !windowClosed(*b, now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if b.OwnPath() {
|
if b.OwnPath() {
|
||||||
// A walk on the controller's own path folds no walk waiting for its word: those merges are batched
|
// A walk on the controller's own path folds no walk waiting for its word (decided during the build,
|
||||||
// again, behind it (decided during the build, 2026-10-10).
|
// 2026-10-10): that walk keeps waiting beside it, and starts once this one ended (advanceOnce).
|
||||||
if err := rebatchWaiting(ctx, inv, waiting, b.ID, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
waiting = nil
|
waiting = nil
|
||||||
}
|
}
|
||||||
if err := cutBatch(ctx, open, b, waiting, now); err != 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
|
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
|
// 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.
|
// 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 {
|
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{}
|
held := map[string]bool{}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
held[e.Manifest.Module] = true
|
held[e.Manifest.Module] = true
|
||||||
@@ -773,9 +766,7 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
|||||||
plan.Commits = commits
|
plan.Commits = commits
|
||||||
newest := newestCommit(commits)
|
newest := newestCommit(commits)
|
||||||
plan.Repository, plan.Branch, plan.Commit, plan.Merged = newest.Repository, newest.Branch, newest.Commit, newest.Merged
|
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
|
plan.Delivery = awaits
|
||||||
// holder on record, a walk that moves no module on the controller's own path is opened and waits.
|
|
||||||
plan.Delivery = awaitsFor(entries, moved)
|
|
||||||
if plan.Delivery == nil {
|
if plan.Delivery == nil {
|
||||||
plan.Delivery = &inventory.PlanDelivery{}
|
plan.Delivery = &inventory.PlanDelivery{}
|
||||||
}
|
}
|
||||||
@@ -1088,6 +1079,77 @@ func groupedWords(b inventory.Plan) string {
|
|||||||
return strings.Join(out, ", ")
|
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.
|
// secondsWords is a short wait as a person reads it.
|
||||||
func secondsWords(d time.Duration) string {
|
func secondsWords(d time.Duration) string {
|
||||||
if d < 2*time.Minute {
|
if d < 2*time.Minute {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func TestTwoMergesSecondsApartAreOneWalkAtTheLaterCommit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
|
want := []inventory.PlanMerge{{Repository: "novox/mesh-catalog", Commit: claude.Commit, Carried: dunst.Commit},
|
||||||
{Repository: "novox/mesh-catalog", Commit: 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)
|
t.Fatalf("the walk answers %+v, want %+v", w.Delivery, want)
|
||||||
}
|
}
|
||||||
if len(*asked) != 2 || (*asked)[0][2] != "main" || (*asked)[1][2] != "main" {
|
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) }
|
inventory.PlanSaved = func(p inventory.Plan) { said = append(said, p) }
|
||||||
t.Cleanup(func() { inventory.PlanSaved = was })
|
t.Cleanup(func() { inventory.PlanSaved = was })
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
hear(t, open, catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second)), now.Add(-19*time.Second))
|
claude := catalogueMerge("553b7191claude", "app", now.Add(-20*time.Second))
|
||||||
hear(t, open, catalogueMerge("48bda475dunst", "notes", now.Add(-2*time.Second)), now.Add(-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)
|
hear(t, open, repoMerge("one", "a6bc0931one", now), now)
|
||||||
out := captured(t, func() error { return plansCommand(t.Context(), nil) })
|
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 " +
|
for _, want := range []string{"assembling: ", " s left (at the latest ", "grouped: novox/mesh-catalog@48bda475 " +
|
||||||
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
|
"(answers 553b7191), novox/one@a6bc0931; plan not yet calculated"} {
|
||||||
if !strings.Contains(first, want) {
|
if !strings.Contains(first, want) {
|
||||||
t.Fatalf("plans' first line %q does not say %q", 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 ||
|
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 {
|
len(said[len(said)-1].Delivery.Merges) != 3 {
|
||||||
t.Fatalf("the batch was not said as assembling with its merges: %+v", said)
|
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))
|
hear(t, open, catalogueMerge("553b7191early", "notes", t0), t0.Add(15*time.Minute))
|
||||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||||
if got.State != inventory.PlanDone || len(got.Delivery.Merges) != 2 ||
|
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}) {
|
Carried: later.Commit}) {
|
||||||
t.Fatalf("the late merge is not named by the walk that carried it: %+v", got.Delivery.Merges)
|
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)
|
ws, _ = walks(t, open)
|
||||||
alone := ws[0]
|
alone := ws[0]
|
||||||
if alone.ID == failed.ID || alone.Commit != middle.Commit || len(alone.Delivery.Merges) != 1 ||
|
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)
|
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",
|
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])
|
t.Fatalf("the search went on after a merge was delivered: %+v", ws[0])
|
||||||
}
|
}
|
||||||
done, _ := open.inventory.PlanByID(ctx, alone.ID)
|
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}) {
|
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)
|
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))
|
hear(t, open, repoMerge("one", "c1", t0.Add(-60*time.Second)), t0.Add(15*time.Minute))
|
||||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
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"}) {
|
Commit: "c1", Carried: "c2"}) {
|
||||||
t.Fatalf("the late merge was not named by the walk that carried it: %+v", got.Delivery.Merges)
|
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.
|
// One walk at a time holds against the delivery's word too: a waiting walk let go beside a started one takes the
|
||||||
func TestAWaitingWalkIsNotLetGoBesideAStartedOne(t *testing.T) {
|
// word and starts once that one ended, asking nothing before.
|
||||||
|
func TestAWalkLetGoBesideAStartedOneStartsOnceItEnded(t *testing.T) {
|
||||||
open := windowed(t)
|
open := windowed(t)
|
||||||
asksWithPaths(t)
|
asked := asksWithPaths(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
hear(t, open, repoMerge("one", "c1", t0), t0)
|
hear(t, open, repoMerge("one", "c1", t0), t0)
|
||||||
cutAt(t, open, t0.Add(2*time.Minute))
|
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 {
|
if err := open.inventory.SavePlan(ctx, &waiting); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err == nil ||
|
if _, err := letGo(ctx, open.inventory, waiting.ID, catalogue.DeliverySeat, "its turn"); err != nil {
|
||||||
!strings.Contains(err.Error(), started[0].ID) {
|
t.Fatalf("the word was refused beside %s: %v", started[0].ID, err)
|
||||||
t.Fatalf("a waiting walk was let go 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
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"}) {
|
Commit: "c1", Carried: "c2"}) {
|
||||||
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
|
t.Fatalf("the earlier merge the catch-up handed over is not named by the walk that carried it: %+v",
|
||||||
got.Delivery.Merges)
|
got.Delivery.Merges)
|
||||||
@@ -706,50 +732,29 @@ func TestAMergeOnTheControllersPathNeverSharesABatch(t *testing.T) {
|
|||||||
if !strings.Contains(batchWords(ownBatch, t0.Add(30*time.Second)), "own path") {
|
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)))
|
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:
|
// Both windows closed, the controller's batch is cut first and starts; the catalogue batch queues behind it,
|
||||||
// the waiting walk is batched again behind it, not folded into 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))
|
cutAt(t, open, t0.Add(2*time.Minute))
|
||||||
ws, _ := walks(t, open)
|
ws, bs := walks(t, open)
|
||||||
if len(ws) != 1 || !ws[0].Waiting() {
|
if len(ws) != 1 || ws[0].Waiting() || ws[0].CommitOf("novox/mesh-controller") != "k1" {
|
||||||
t.Fatalf("the catalogue batch was not cut into a waiting walk: %+v", ws)
|
t.Fatalf("the controller's batch was not cut first into a started 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)
|
|
||||||
}
|
}
|
||||||
|
ownWalk := ws[0]
|
||||||
for _, m := range []string{"app", "notes"} {
|
for _, m := range []string{"app", "notes"} {
|
||||||
if _, in := ownWalk.Modules[m]; in {
|
if _, in := ownWalk.Modules[m]; in {
|
||||||
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
|
t.Fatalf("the controller's walk builds %s, a catalogue module: it skipped its turn", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, a := range *asked {
|
if len(bs) != 1 || bs[0].State != inventory.PlanQueued || bs[0].Delivery.Batch.Behind != ownWalk.ID || bs[0].OwnPath() {
|
||||||
if a[1] == "modules/app" || a[1] == "modules/notes" {
|
t.Fatalf("the catalogue batch does not queue behind the controller's walk: %+v", bs)
|
||||||
t.Fatalf("a catalogue module was asked without the word: %v", *asked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
ownWalk.State = inventory.PlanDone
|
||||||
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
if err := open.inventory.SavePlan(ctx, &ownWalk); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cutAt(t, open, t0.Add(3*time.Minute))
|
cutAt(t, open, t0.Add(3*time.Minute))
|
||||||
ws, bs = walks(t, open)
|
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)
|
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"} {
|
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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"))
|
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.
|
// **One walk at a time** (novox/hq ADR 0276): the word is taken, and the walk starts once no other walk is
|
||||||
open, err := inv.OpenPlans(ctx)
|
// started (advanceOnce), so the delivery's owner says it once and is not refused.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
p.Delivery.Go, p.Delivery.By, p.Delivery.Why = &now, by, why
|
||||||
p.Note = "let go by " + by + "; its first tier is asked next"
|
p.Note = "let go by " + by + "; its first tier is asked next"
|
||||||
|
|||||||
@@ -446,6 +446,44 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
|
|||||||
advanceHeld(ctx, open)
|
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
|
// 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
|
// 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.
|
// 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) {
|
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 {
|
if err := askTier(ctx, inv, p); err != nil {
|
||||||
return false, err
|
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
|
// 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
|
// 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
|
// 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 {
|
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))
|
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||||
switch p.State {
|
switch p.State {
|
||||||
case inventory.PlanAssembling, inventory.PlanQueued:
|
case inventory.PlanAssembling, inventory.PlanQueued:
|
||||||
// A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left.
|
// A batch not yet a walk (novox/hq ADR 0276): what it holds and how long is left.
|
||||||
return batchWords(p, now)
|
return batchWords(p, now)
|
||||||
case inventory.PlanDone:
|
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:
|
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:
|
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)
|
since := now.Sub(inTierSince(p)).Round(time.Second)
|
||||||
if p.Waiting() {
|
if p.Waiting() {
|
||||||
// Waiting for its delivery's word is no lateness of the walk's (novox/hq ADR 0239).
|
// 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 {
|
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 := ""
|
late := ""
|
||||||
if since > bound {
|
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 {
|
if p.State == inventory.PlanRolling {
|
||||||
what = p.Note
|
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
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
bounds := readTierBounds(ctx, inv, now)
|
bounds := readTierBounds(ctx, inv, now)
|
||||||
fmt.Printf("%s — %s\n", p.ID, planLineWith(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
fmt.Printf("%s — %s\n", p.ID, planLineOn(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||||
bounds.of(p.Repository)))
|
bounds.of(p.Repository), func(module string) []string {
|
||||||
|
on, _ := inv.Running(ctx, module)
|
||||||
|
return on
|
||||||
|
}))
|
||||||
if r := p.Release; r != nil {
|
if r := p.Release; r != nil {
|
||||||
// A release plan's walk (ADR 0236): machines done, the one judged, those to come.
|
// 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, ", "),
|
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 {
|
for _, b := range batches {
|
||||||
fmt.Printf("%-28s %s\n", b.ID, batchWords(b, now))
|
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)
|
pause := buildSeatPause(ctx, inv, plans)
|
||||||
bounds := readTierBounds(ctx, inv, now)
|
bounds := readTierBounds(ctx, inv, now)
|
||||||
|
running := func(module string) []string {
|
||||||
|
on, _ := inv.Running(ctx, module)
|
||||||
|
return on
|
||||||
|
}
|
||||||
for _, p := range plans {
|
for _, p := range plans {
|
||||||
if p.Batch() {
|
if p.Batch() {
|
||||||
continue
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ type PlanMerge struct {
|
|||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
Carried string `json:"carried,omitempty"`
|
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
|
// PlanBatch is a batch's window while it is one (novox/hq ADR 0276): when it closes unless another merge
|
||||||
|
|||||||
@@ -210,9 +210,11 @@ type SourceMoved struct {
|
|||||||
// stands: a directory is a module only when the merge changed its manifest.
|
// stands: a directory is a module only when the merge changed its manifest.
|
||||||
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
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
|
// Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery
|
||||||
// `after:` lines, read when its members are merged into one batch and their order becomes the walk's.
|
// 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"`
|
Number int `json:"number,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user