Let delivery-stop leave merges out and walk the others again, so a group stop ends only its own change (hq issue 459, ADR 0299)
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-group group feat/459-a-stop-re-walks-the-others ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request

One walk carries every merge of its batch, so ending it for one group member
ended every other delivery on it. delivery-stop now takes without: the walk
ends, every other merge it answered is batched again, and each later merge of
a left-out merge's repository is left out too, since it contains it.
This commit is contained in:
2026-10-11 19:54:12 +02:00
parent 3b017b228c
commit fbfdded74b
5 changed files with 370 additions and 10 deletions
+168 -8
View File
@@ -117,28 +117,180 @@ func letGo(ctx context.Context, inv *inventory.Inventory, id, by, why string) (i
// stopWalk ends a walk on its delivery's word: failed, said as stopped by whom, why. What it asked still
// builds and registers; nothing further is asked or sent.
func stopWalk(ctx context.Context, inv *inventory.Inventory, id, by, why string) (inventory.Plan, error) {
p, _, err := stopWalkWithout(ctx, inv, id, by, why, nil, time.Now().UTC())
return p, err
}
// stopAnswer is what a stop that leaves merges out says, as data (novox/hq ADR 0299): the walk stopped, the
// batch that walks its other merges again, those merges as `<repository>@<commit>`, and every merge left out.
type stopAnswer struct {
Stopped string `json:"stopped"`
WalkedAgainBy string `json:"walked_again_by,omitempty"`
WalkedAgain []string `json:"walked_again,omitempty"`
LeftOut []inventory.PlanLeftOut `json:"left_out,omitempty"`
}
// stopWalkWithout ends a walk on its delivery's word and, given merges to leave out, walks every other merge it
// answered again in a fresh batch without them (novox/hq issue 459, ADR 0299): one walk carries every merge of its
// batch, so ending it for one group member ended every other delivery on it. Left out are the merges named and
// each later merge of their repository's branch, which contains one of them and cannot be walked without
// delivering it. The fresh batch's window is closed — its merges were heard before — and it is cut by the usual
// rules, as a failed walk's merges are walked again (ADR 0276 decision 3). With nothing left to walk, or no merge
// named, the walk is only ended. A merge named that the walk does not answer refuses the stop whole.
func stopWalkWithout(ctx context.Context, inv *inventory.Inventory, id, by, why string, without []string,
now time.Time) (inventory.Plan, stopAnswer, error) {
said := stopAnswer{Stopped: id}
release, err := inv.HoldPlans(ctx, true)
if err != nil {
return inventory.Plan{}, err
return inventory.Plan{}, said, err
}
defer release()
p, err := inv.PlanByID(ctx, id)
if err != nil {
return inventory.Plan{}, err
return inventory.Plan{}, said, err
}
if !p.Open() {
return p, fmt.Errorf("%s is already %s", p.ID, p.State)
return p, said, fmt.Errorf("%s is already %s", p.ID, p.State)
}
var again []inventory.BatchedMerge
if len(without) > 0 {
merges, err := inv.MergesOf(ctx, p.ID)
if err != nil {
return p, said, err
}
if again, said.LeftOut, err = leaveOut(merges, without); err != nil {
return p, said, fmt.Errorf("%s is not stopped: %w", p.ID, err)
}
}
if p.Delivery == nil {
p.Delivery = &inventory.PlanDelivery{}
}
p.Delivery.Stopped, p.Delivery.StoppedWhy = by, why
p.Delivery.LeftOut = said.LeftOut
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("stopped by %s at tier %d: %s", by, p.Tier, why)
if err := inv.SavePlan(ctx, &p); err != nil {
return p, err
if len(again) > 0 {
batch, err := walkAgain(ctx, inv, p, again, now)
if err != nil {
return p, said, fmt.Errorf("%s is not stopped: its other merges could not be batched again: %w", p.ID, err)
}
said.WalkedAgainBy = batch.ID
for _, m := range again {
said.WalkedAgain = append(said.WalkedAgain, m.Repository+"@"+m.Commit)
}
p.Delivery.WalkedAgainBy = batch.ID
p.Note += fmt.Sprintf("; its other merges are walked again without %s, by %s: %s", mergesLeftOutWords(said.LeftOut),
batch.ID, strings.Join(said.WalkedAgain, ", "))
if err := inv.SavePlan(ctx, &p); err != nil {
// The merges go back to the walk that still answers them, and the batch holds none: it is never walked.
for _, m := range again {
_ = inv.AnswerMerge(ctx, m.Repository, m.Commit, p.ID, m.Alone)
}
return p, said, err
}
fmt.Printf("%s stopped by %s; %s walks its other merges again: %s\n", p.ID, by, batch.ID,
strings.Join(said.WalkedAgain, ", "))
return p, said, nil
}
return p, nil
if len(said.LeftOut) > 0 {
p.Note += fmt.Sprintf("; left out %s, nothing else to walk", mergesLeftOutWords(said.LeftOut))
}
if err := inv.SavePlan(ctx, &p); err != nil {
return p, said, err
}
return p, said, nil
}
// walkAgain puts merges of a stopped walk into a fresh batch of their kind, its window closed: kept before its
// first merge names it, then each merge moved to it.
func walkAgain(ctx context.Context, inv *inventory.Inventory, stopped inventory.Plan, again []inventory.BatchedMerge,
now time.Time) (inventory.Plan, error) {
own := false
for _, m := range again {
for _, n := range announcedOf(m).Moves {
if _, on := onTheControllersPath[n]; on {
own = true
}
}
}
first := again[0]
b := inventory.Plan{ID: fmt.Sprintf("plan-%d", now.UnixNano()), Repository: first.Repository, Branch: first.Branch,
Commit: first.Commit, Merged: first.Merged, Created: now, State: inventory.PlanAssembling,
Tiers: [][]string{}, Modules: map[string]*inventory.PlanModule{},
Delivery: &inventory.PlanDelivery{Batch: &inventory.PlanBatch{Own: own}}}
if b.ID == stopped.ID {
b.ID += "-again"
}
if err := inv.SavePlan(ctx, &b); err != nil {
return b, err
}
for _, m := range again {
if err := inv.AnswerMerge(ctx, m.Repository, m.Commit, b.ID, m.Alone); err != nil {
return b, err
}
}
// Its merges were heard before: the window, measured from the last of them, is closed, and the cutter cuts it
// once no walk of its kind is open.
return b, keepBatch(ctx, inv, &b, now, "")
}
// leaveOut splits a walk's merges by the merges named to leave out, each `<repository>@<commit>` (a commit's
// first characters will do): the merges walked again, and those left out — each named one, and each later merge
// of its repository's branch, which contains it. A name the walk does not answer is refused.
func leaveOut(merges []inventory.BatchedMerge, without []string) ([]inventory.BatchedMerge, []inventory.PlanLeftOut, error) {
var stopped []inventory.BatchedMerge
for _, w := range without {
repository, commit, ok := strings.Cut(strings.TrimSpace(w), "@")
if !ok || repository == "" || len(commit) < 7 {
return nil, nil, fmt.Errorf("%q names no merge: <repository>@<commit>", w)
}
i := slices.IndexFunc(merges, func(m inventory.BatchedMerge) bool {
return strings.EqualFold(m.Repository, repository) && strings.HasPrefix(m.Commit, commit)
})
if i < 0 {
return nil, nil, fmt.Errorf("it answers no merge %s@%s", repository, commit)
}
stopped = append(stopped, merges[i])
}
// The earliest stopped merge of each repository's branch: every merge of that branch after it contains it.
earliest := map[string]inventory.BatchedMerge{}
for _, s := range stopped {
k := strings.ToLower(s.Repository) + "\x00" + s.Branch
if e, ok := earliest[k]; !ok || laterMerge(e, s) {
earliest[k] = s
}
}
var again []inventory.BatchedMerge
var out []inventory.PlanLeftOut
for _, m := range merges {
named := slices.ContainsFunc(stopped, func(s inventory.BatchedMerge) bool {
return strings.EqualFold(s.Repository, m.Repository) && s.Commit == m.Commit
})
e, has := earliest[strings.ToLower(m.Repository)+"\x00"+m.Branch]
switch {
case named:
out = append(out, inventory.PlanLeftOut{Repository: m.Repository, Commit: m.Commit})
case has && laterMerge(m, e):
out = append(out, inventory.PlanLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: e.Commit})
default:
again = append(again, m)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Contains == "" && out[j].Contains != "" })
return again, out, nil
}
// mergesLeftOutWords is the merges a stop left out, as a person reads them: each that contains a stopped one says so.
func mergesLeftOutWords(out []inventory.PlanLeftOut) string {
var words []string
for _, o := range out {
s := o.Repository + "@" + short(o.Commit)
if o.Contains != "" {
s += " (which contains " + short(o.Contains) + ")"
}
words = append(words, s)
}
return strings.Join(words, ", ")
}
// orderMember is one member of a group, as mesh-delivery says it.
@@ -466,6 +618,8 @@ func deliveryCommand(ctx context.Context, args []string) error {
group := set.String("group", "", "a delivery group's id")
by := set.String("by", catalogue.DeliverySeat, "who says it")
why := set.String("why", "", "why")
without := set.String("without", "", "merges a stop leaves out, <repository>@<commit>, comma-separated: the walk's "+
"other merges are walked again without them (novox/hq ADR 0299)")
limit := set.Int("n", 50, "how many ended walks to answer beside the open ones")
planID := set.String("plan", "", "one walk")
positionals, err := parseAround(set, rest)
@@ -552,15 +706,21 @@ func deliveryCommand(ctx context.Context, args []string) error {
return nil
case "stop":
if len(positionals) != 1 || strings.TrimSpace(*why) == "" {
return errors.New("delivery stop <plan> --why <text> [--by <who>]")
return errors.New("delivery stop <plan> --why <text> [--by <who>] [--without <repository>@<commit>,…]")
}
var p inventory.Plan
var said stopAnswer
if err := sayingOnTheBus(ctx, func() (err error) {
p, err = stopWalk(ctx, inv, positionals[0], *by, strings.TrimSpace(*why))
p, said, err = stopWalkWithout(ctx, inv, positionals[0], *by, strings.TrimSpace(*why), splitList(*without),
time.Now().UTC())
return err
}); err != nil {
return err
}
if *without != "" {
// What it left out and what it walks again, as data: the delivery's owner reads it (ADR 0299).
return answer(said)
}
fmt.Printf("%s stopped by %s at tier %d of %d; what was asked still builds and registers, nothing further "+
"is asked or sent\n", p.ID, *by, p.Tier, len(p.Tiers))
return nil