From ff2632356b335b1bb1645dc20e588cdbb49a202d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:02:04 +0100 Subject: [PATCH 1/6] The gate says whether a merged delivery is on the machines, derived from the view's reach, and a person settles a merge held without a passing check on the operator's answer (issue 1280, ADR 0556 clauses 4 and 5) --- .../cmd/mesh-delivery-gate/approval.go | 9 +- .../cmd/mesh-delivery-gate/holder.go | 6 +- .../cmd/mesh-delivery-gate/main.go | 50 +++- .../cmd/mesh-delivery-gate/manifest_test.go | 10 +- .../cmd/mesh-delivery-gate/model.go | 4 + .../cmd/mesh-delivery-gate/reached.go | 88 +++++++ .../cmd/mesh-delivery-gate/reached_test.go | 65 ++++++ .../cmd/mesh-delivery-gate/settle.go | 215 ++++++++++++++++++ .../cmd/mesh-delivery-gate/settle_test.go | 87 +++++++ .../cmd/mesh-delivery-gate/table.go | 24 ++ .../cmd/mesh-delivery-gate/table_test.go | 14 +- .../cmd/mesh-delivery-gate/verbs.go | 14 +- modules/mesh-delivery-gate/module.json | 6 +- 13 files changed, 573 insertions(+), 19 deletions(-) create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index 3c385094..d835ffb4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -579,7 +579,7 @@ func (h *Holder) hearAsks() { return } h.mu.Lock() - keys := h.pendingAsks() + keys := append(h.pendingAsks(), h.settleKeys()...) h.mu.Unlock() for _, key := range keys { answer, err := h.Operator.Where(key) @@ -591,7 +591,12 @@ func (h *Holder) hearAsks() { continue } h.mu.Lock() - said := h.answered(key, *answer) + var said []func() + if h.isSettleKey(key) { + h.settled(key, *answer) // a person's settle ask (settle.go, novox/hq ADR 0556 clause 5) + } else { + said = h.answered(key, *answer) + } h.mu.Unlock() for _, s := range said { s() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go index 138c7589..9d690822 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -89,6 +89,9 @@ type Holder struct { Turns TurnStore turnsHeld map[string]sdk.Turn turnsLoaded bool + // reach is the delivery view's reach of each repository, by its key (reached.go, novox/hq ADR 0556): what `deliveries` + // and `show` derive whether a merge is on the machines from. Nothing here records it. + reach map[string]sdk.Reach // tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407). tried owedTries // forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have @@ -139,6 +142,7 @@ func (h *Holder) init() { h.unchecked = map[string]unchecked{} h.checker = map[string]checkerVerdict{} h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{} + h.reach = map[string]sdk.Reach{} h.started = h.Now() } @@ -757,7 +761,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) { } ts = append(ts, settle(d, h.factsAt(now))...) if d.State == Held && d.HeldWhy == "" { - d.HeldWhy = "merged without a passing check: only a person decides what follows" + d.HeldWhy = HeldUnchecked } h.moved(d, ts) h.closeGroupOf(d) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go index be04c7f1..52d68da4 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -2,8 +2,9 @@ // runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to // every machine — and the delivery group above it: their states in one compiled table, kept in its own state // on the bus, every transition said as an event, noted on the commit and shown on the pull request through -// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and derives nothing -// after the merge (ADR 0539 §17). stdout +// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and records nothing +// after the merge (ADR 0539 §17): whether a merge is on the machines it derives when asked from the delivery view's reach +// (ADR 0556 clause 4), and a merge held without a passing check is settled on the operator's answer (clause 5). stdout // is the MCP channel; what this module says, it says on stderr. package main @@ -116,6 +117,21 @@ func run(h *Holder, l *listening) { time.Sleep(wait) } }() + // The delivery view's reach (novox/hq ADR 0556 clause 4): how far each repository reached, which `deliveries` and `show` + // derive whether a merge is on the machines from. Each change is kept; nothing is moved by it. + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.State(reachState).Watch("", func(c stdio.StateChange) error { + h.ReachChanged(c.Key, c.Value, c.Op == "delete") + return nil + }) + if err == nil { + return + } + logf("[mesh-delivery] not hearing the delivery view's reach yet (%v); asking again in %s", err, wait) + time.Sleep(wait) + } + }() // The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the // heads an ask covers, and a group's verdict is that one; each change asks for a look. go func() { @@ -290,7 +306,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { list := []stdio.Tool{ {Name: seat + "deliveries", Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " + - "(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " + + "(owner/repository@commit), its state, its group, what it waits for and since when, and for a merged one whether it is on " + + "the machines (machines, derived from the delivery view's reach, novox/hq ADR 0556). Narrowed by state, " + "repository or group.", Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"), "repository": str("owner/repository"), "group": str("a group's id (its branch name)"), @@ -301,7 +318,8 @@ func tools(h *Holder, l *listening) []stdio.Tool { }}, {Name: seat + "show", Description: "One delivery or group whole: every transition with when and why, and its group. " + - "What the merge moved is source.main; whether it reached the machines is the view's.", + "For a merged delivery, whether its merge is on the machines (machines), derived from the delivery view's reach of its " + + "repository (novox/hq ADR 0556), and else what holds it.", Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")}, "required": []string{"id"}}, Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }}, @@ -362,6 +380,30 @@ func tools(h *Holder, l *listening) []stdio.Tool { } return h.Close(strArg(a, "id"), strArg(a, "why")) }}, + {Name: seat + "settle", + Description: "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: " + + "one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's " + + "answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). " + + "Refused, nothing asked, when any delivery named is not held or is held for another reason, naming why.", + Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"}, + "properties": map[string]any{ + "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1, + "description": "the held deliveries' ids (owner/repository@commit)"}, + "why": str("why they may be settled, shown to the operator")}}, + Run: func(a map[string]any) (any, error) { + var ids []string + switch v := a["ids"].(type) { + case []any: + for _, x := range v { + if s, ok := x.(string); ok { + ids = append(ids, s) + } + } + case []string: + ids = v + } + return h.Settle(ids, strArg(a, "why"), aPerson) + }}, {Name: "delivery_turns", Description: "Every merge turn (novox/hq ADR 0548 §3): per repository, the approved merges that wait to merge into it in the order of " + "their Approve, the check of another delivery each waits for and since when, whether the gate wrote the turn to its bucket " + diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go index 4ca96530..4e16ea29 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/manifest_test.go @@ -70,8 +70,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if len(m.Seats) != 0 { t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own") } - // The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans. - if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close"}; !reflect.DeepEqual(m.Claims[0].Serves, want) { + // The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans; and + // settle, a person's ask to settle merges held without a passing check (ADR 0556 clause 5). + if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close", "settle"}; !reflect.DeepEqual(m.Claims[0].Serves, want) { t.Fatalf("serves %v, wanted %v", m.Claims[0].Serves, want) } // R6-C1: no controller verb is invoked and no plan event is consumed. @@ -98,8 +99,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { t.Fatalf("state %v", buckets) } // What it reads: the teller's answers and the checker's verdicts (issue 1302). The carry from today's module's buckets, the - // seat's holder and the machines' haves went with the seat's hand-over (ADR 0539 §17). - if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts"}) { + // seat's holder and the machines' haves went with the seat's hand-over (ADR 0539 §17). The delivery view's reach, which + // `deliveries` and `show` derive whether a merge is on the machines from (ADR 0556 clause 4); no forge tool is asked for it. + if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-delivery-view.reach"}) { t.Fatalf("reads %v", m.Reads) } // It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go index 9f2a4de8..e46c968f 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -105,6 +105,10 @@ type Delivery struct { // trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked. FreshWanted bool `json:"fresh_wanted,omitempty"` + // Settle is the settle ask a person had written for it while it is held for a merge without a passing check (settle.go, + // novox/hq ADR 0556 clause 5): its key in the gate's asks, why, who asked and when, and the operator's answer once read. + Settle *SettleAsk `json:"settle,omitempty"` + Transitions []Transition `json:"transitions,omitempty"` // Owed are the forge's and the bus's effects of its transitions not yet done: said only after the // delivery is kept, retried until done. diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go new file mode 100644 index 00000000..5bf5fb37 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "strings" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Whether a merge is on the machines (novox/hq ADR 0556 clause 4). The gate records only the merge (ADR 0539 §17, ADR 0429 +// §1): it reads the delivery view's reach of each repository, which names the repository's reached head (the newest merge every +// node runs a build containing), and derives when asked, for a merged delivery, whether its merge is at or before that head. +// The order of two merges into one trunk is the gate's own: the moment each was merged, the trunk being a line of merges. A +// reached head the gate holds no merge of is said, never asked of the forge. + +// reachState is the delivery view's state the gate reads. +const reachState = sdk.ViewModule + ".reach" + +// ReachChanged takes one change of the view's reach. +func (h *Holder) ReachChanged(key string, value []byte, deleted bool) { + h.mu.Lock() + defer h.mu.Unlock() + if deleted { + delete(h.reach, key) + return + } + r, err := sdk.DecodeReach(value) + if err != nil { + h.Logf("[mesh-delivery] the view's reach %s is not readable, so its merges read as not known: %v", key, err) + delete(h.reach, key) + return + } + h.reach[key] = r +} + +// The words a merged delivery's machines are said in. +const ( + onMachines = "on the machines" + notYetOn = "not yet on the machines: " + notKnownWhere = "not known: " +) + +// machines says whether a merged delivery's merge is on the machines, derived from the view's reach; empty for a delivery +// that is not merged. Under the lock. +func (h *Holder) machines(d *Delivery) string { + if d.State != Merged || d.MergedAs == "" { + return "" + } + key, err := sdk.RepositoryKey(d.Repository) + if err != nil { + return notKnownWhere + err.Error() + } + r, ok := h.reach[key] + switch { + case !ok: + return notKnownWhere + "the delivery view says no reach of " + d.Repository + case r.Reached == "": + return notYetOn + holdsSaid(r) + case sameCommit(d.MergedAs, r.Reached): + return onMachines + } + head := h.mergeOf(d.Repository, r.Reached) + switch { + case head == nil: + return notKnownWhere + fmt.Sprintf("the reached head %s of %s is no merge this gate holds", shortOf(r.Reached, 12), d.Repository) + case !d.MergedAt.After(head.MergedAt): + return onMachines + } + return notYetOn + holdsSaid(r) +} + +// holdsSaid is what holds a repository's next head, in words. +func holdsSaid(r sdk.Reach) string { + if len(r.Holds) == 0 { + return "the delivery view names nothing that holds it yet" + } + return strings.Join(r.Holds, "; ") +} + +// mergeOf is the delivery of a repository that merged as the commit; nil when none does. Under the lock. +func (h *Holder) mergeOf(repository, commit string) *Delivery { + for _, d := range h.deliveries { + if d.MergedAs != "" && strings.EqualFold(d.Repository, repository) && sameCommit(d.MergedAs, commit) { + return d + } + } + return nil +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go new file mode 100644 index 00000000..89493406 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "strings" + "testing" + "time" + + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// 1280 (novox/hq ADR 0556 clause 4): `deliveries` and `show` say for a merged delivery whether it is on the machines, derived +// when asked from the view's reach and the gate's own order of merges: at or before the reached head it is, after it not yet +// and why; a repository the view has no reach of, or a reached head the gate holds no merge of, is not known. Nothing records it. +func TestAMergedDeliverySaysWhetherItIsOnTheMachines(t *testing.T) { + w := newWorld(t) + at := w.now.Add(-3 * time.Hour) + merge := func(commit string, n int) *Delivery { + d := &Delivery{ID: "novox/web@" + commit[:12], Repository: "novox/web", Commit: commit[:12], Number: n, State: Merged, + Since: at, MergedAs: strings.Repeat(commit[:1], 40), MergedAt: at.Add(time.Duration(n) * time.Minute)} + w.h.mu.Lock() + w.h.keep(d) + w.h.mu.Unlock() + return d + } + first, second, third := merge("111111111111", 1), merge("222222222222", 2), merge("333333333333", 3) + reach := func(r sdk.Reach) { + key, _ := sdk.RepositoryKey(r.Repository) + raw, err := sdk.EncodeReach(r) + if err != nil { + t.Fatal(err) + } + w.h.ReachChanged(key, raw, false) + } + machines := func(d *Delivery) string { w.h.mu.Lock(); defer w.h.mu.Unlock(); return w.h.machines(d) } + if got := machines(first); !strings.HasPrefix(got, notKnownWhere) { + t.Errorf("with no reach: %q", got) + } + reach(sdk.Reach{Repository: "novox/web", Reached: second.MergedAs, ReachedSrc: 7, Holds: []string{"web on g14 runs the build of 2222, not yet that of 3333"}}) + for d, want := range map[*Delivery]string{first: onMachines, second: onMachines, third: notYetOn + "web on g14"} { + if got := machines(d); !strings.HasPrefix(got, want) { + t.Errorf("%s: %q, want %q", d.ID, got, want) + } + } + reach(sdk.Reach{Repository: "novox/web", Reached: strings.Repeat("9", 40), ReachedSrc: 8}) + if got := machines(first); !strings.Contains(got, "no merge this gate holds") { + t.Errorf("a reached head the gate holds no merge of: %q", got) + } + reach(sdk.Reach{Repository: "novox/web", Holds: []string{"web on ace runs nothing yet"}}) + if got := machines(first); got != notYetOn+"web on ace runs nothing yet" { + t.Errorf("no reached head: %q", got) + } + // The verbs say it; a delivery not merged says nothing of the machines. + reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9}) + lines := w.h.Deliveries("", "novox/web", "", true) + if len(lines) != 3 || lines[0].Machines != onMachines { + t.Errorf("deliveries = %+v", lines) + } + shown, err := w.h.Show(third.ID) + if err != nil || shown.(map[string]any)["machines"] != onMachines { + t.Errorf("show = %v, %v", shown, err) + } + if got := machines(&Delivery{State: Held, MergedAs: "x", Repository: "novox/web"}); got != "" { + t.Errorf("a held delivery says %q of the machines", got) + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go new file mode 100644 index 00000000..85263657 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go @@ -0,0 +1,215 @@ +package main + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A person settles a delivery held because its merge reached the trunk without a passing check (novox/hq ADR 0556 clause 5). +// The verb `settle` moves nothing: it writes one ask, for every delivery named, into the gate's asks, which the teller puts to +// the operator as it puts an Approve, its options binding mesh-delivery.settle (sdk.AskVerb). On the answer settle each +// delivery takes the table's asked row held → merged with the operator's words; on leave they stay held. Whether a settled +// merge is on the machines is then derived as for any merge (reached.go). A hold of another kind, or a delivery not held, is +// refused, naming why. + +// The settle ask's two answers, both at the level approve. +const ( + OptionSettle = "settle" + OptionLeave = "leave" +) + +// SettleAsk is the settle ask a delivery is covered by. +type SettleAsk struct { + Key string `json:"key"` + Asking int `json:"asking,omitempty"` + Why string `json:"why"` + By string `json:"by,omitempty"` + At time.Time `json:"at"` + Answered *time.Time `json:"answered,omitempty"` + Answer string `json:"answer,omitempty"` + Words string `json:"words,omitempty"` +} + +// Settle is the `settle` verb: one ask of the operator for every delivery named, each held for a merge without a passing +// check. Nothing moves until the operator answers. +func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) { + why = strings.TrimSpace(why) + if why == "" { + return nil, fmt.Errorf("settle says why: it is shown to the operator") + } + if len(ids) == 0 { + return nil, fmt.Errorf("settle names at least one held delivery (ids)") + } + if h.Operator == nil { + return nil, fmt.Errorf("this owner has no way to ask the operator") + } + h.mu.Lock() + var covered []*Delivery + var refused []string + seen := map[string]bool{} + for _, id := range ids { + id = strings.TrimSpace(id) + if seen[id] { + continue + } + seen[id] = true + d := h.deliveries[id] + switch { + case d == nil: + refused = append(refused, id+": no such delivery") + case d.State != Held: + refused = append(refused, fmt.Sprintf("%s: it is %s, not held", id, d.State)) + case !heldUnchecked(d): + refused = append(refused, fmt.Sprintf("%s: it is held for another reason, which settle does not take (%s)", id, d.HeldWhy)) + default: + covered = append(covered, d) + } + } + if len(refused) > 0 { + h.mu.Unlock() + sort.Strings(refused) + return nil, fmt.Errorf("settle refused, nothing asked: %s", strings.Join(refused, "; ")) + } + sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID }) + var names []string + var heads []sdk.Head + for _, d := range covered { + names = append(names, d.ID) + heads = append(heads, sdk.Head{Repository: d.Repository, Number: d.Number, Head: d.Commit}) + } + group := sdk.SettleGroup(names) + key, err := sdk.MergeAskKey(group, heads) + if err != nil { + h.mu.Unlock() + return nil, fmt.Errorf("the settle ask has no key: %w", err) + } + now := h.Now() + // The same deliveries asked again (the ask lapsed, or was left unanswered) are a new asking of the same key, under its own + // router id: the router drops a second ask under an id it kept. + asking := 1 // the first asking (sdk.Ask.Asking: 0 or 1 is the first, n the n-th) + for _, d := range covered { + if d.Settle != nil && d.Settle.Key == key && max(d.Settle.Asking, 1) >= asking { + asking = max(d.Settle.Asking, 1) + 1 + } + } + ask := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: composeSettleAsk(sdk.AskingID(key, asking), covered, why, now)} + for _, d := range covered { + d.Settle = &SettleAsk{Key: key, Asking: asking, Why: why, By: by, At: now} + h.keep(d) + } + h.mu.Unlock() + if err := h.Operator.Ask(key, ask); err != nil { + return nil, fmt.Errorf("the settle ask %s could not be written (call settle again): %w", key, err) + } + h.Logf("[mesh-delivery] asked the operator to settle %s: %s", strings.Join(names, ", "), why) + return map[string]any{"asked": key, "deliveries": names, + "said": fmt.Sprintf("The operator is asked once to settle %d deliveries; nothing moves until the answer.", len(covered))}, nil +} + +// settleAct is what a settle ask's answer binds: its verb (mesh-delivery.settle), this ask, the answer and the heads. +func settleAct(group, ask, option string, heads map[string]string) asks.Act { + return asks.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)} +} + +// composeSettleAsk is the operator's ask to settle these held deliveries. +func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks.Ask { + var a asks.Ask + a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts) + a.OnExpiry = "they stay held; settle asks again" + a.About = "delivery.settle" + var pulls, lines []string + heads := map[string]string{} + for _, d := range ds { + pulls = append(pulls, pullSaid(d)) + heads[d.ID] = d.Commit + lines = append(lines, phone(fmt.Sprintf("%s %q merged at %s without a passing check", pullSaid(d), d.Title, + shortOf(d.MergedAs, 12)), 400)) + } + a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), asks.HeadlineLength) + a.Explanation = strings.Join([]string{ + phone(fmt.Sprintf("%s reached main without a passing check and are held. Settle ends the hold: each reads merged, and on the machines once every machine runs it. Leave keeps them held.", + strings.Join(pulls, ", ")), asks.SummaryLength), + phone("Why: "+why, asks.PointLength), + }, "\n") + a.Details = strings.Join(lines, "\n") + group := sdk.SettleGroup(func() []string { + var ids []string + for _, d := range ds { + ids = append(ids, d.ID) + } + return ids + }()) + for _, o := range []struct{ id, label, does string }{ + {OptionSettle, "Settle", "each delivery named ends its hold and reads merged"}, + {OptionLeave, "Leave", "they stay held"}, + } { + binds, _ := asks.ActDigest(settleAct(group, id, o.id, heads)) + a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) + } + return a +} + +// isSettleKey is whether an ask key is a settle ask's; under the lock. +func (h *Holder) isSettleKey(key string) bool { + _, group, _, err := sdk.ParseAskKey(key) + return err == nil && strings.HasPrefix(group, sdk.SettleGroupPrefix) +} + +// settleKeys are the settle asks waiting for an answer; under the lock. +func (h *Holder) settleKeys() []string { + seen := map[string]bool{} + var out []string + for _, d := range h.deliveries { + if s := d.Settle; s != nil && s.Answered == nil && d.State == Held && !seen[s.Key] { + seen[s.Key] = true + out = append(out, s.Key) + } + } + sort.Strings(out) + return out +} + +// settled applies the teller's answer to a settle ask; under the lock. The teller held the warrant (the act it bound is +// mesh-delivery.settle); an answer to an earlier asking, or one choosing what the ask did not offer, moves nothing. +func (h *Holder) settled(key string, ans sdk.Answer) { + var covered []*Delivery + for _, d := range h.deliveries { + if d.Settle != nil && d.Settle.Key == key && d.Settle.Answered == nil { + covered = append(covered, d) + } + } + if len(covered) == 0 { + return + } + sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID }) + if asked, at, ok := strings.Cut(ans.Warrant, "@"); ok && at != "" && asked != sdk.AskingID(key, covered[0].Settle.Asking) { + h.Logf("[mesh-delivery] the answer to the settle ask %s was given to another asking (%s): not taken", key, asked) + return + } + if ans.Option != OptionSettle && ans.Option != OptionLeave { + h.refuse(fmt.Errorf("the answer to the settle ask %s chose %s, which it did not offer: nothing settled", key, ans.Option)) + return + } + now := h.Now() + for _, d := range covered { + d.Settle.Answered, d.Settle.Answer, d.Settle.Words = &now, ans.Option, ans.Words + if ans.Option == OptionLeave { + h.Logf("[mesh-delivery] %s: the operator left it held (%s)", d.ID, ans.Words) + h.keep(d) + continue + } + t, err := Apply(d, EvSettled, true, Facts{Now: now, By: "the operator", Why: "settled: " + ans.Words}) + if err != nil { + h.refuse(err) + h.keep(d) + continue + } + d.HeldWhy = "" + h.moved(d, []Transition{t}) + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go new file mode 100644 index 00000000..9ccb8cea --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + sdk "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// 1280 (novox/hq ADR 0556 clause 5): settle writes one ask for several deliveries held for a merge without a passing check and +// moves none of them; it refuses, asking nothing, a delivery held for another reason and one not held, naming why; the +// operator's Leave keeps them held, and Settle, asked again under a new asking, moves each to merged with the operator's words. +func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) { + w := newWorld(t) + w.op.auto = false + merged := w.now.Add(-time.Hour) + put := func(id string, state State, why string) *Delivery { + repo, commit, _ := strings.Cut(id, "@") + d := &Delivery{ID: id, Repository: repo, Commit: commit, Number: len(id), Title: "a fix", State: state, Since: merged, + MergedAs: "m" + commit, MergedAt: merged, HeldWhy: why} + w.h.mu.Lock() + w.h.keep(d) + w.h.mu.Unlock() + return d + } + a := put("novox/web@aaaaaaaaaaaa", Held, HeldUnchecked) + b := put("novox/sdk@bbbbbbbbbbbb", Held, HeldUnchecked) + put("novox/web@cccccccccccc", Held, "a conflict on the trunk") + put("novox/web@dddddddddddd", Merged, "") + for ids, words := range map[string]string{ + "novox/web@aaaaaaaaaaaa novox/web@cccccccccccc": "held for another reason", + "novox/web@aaaaaaaaaaaa novox/web@dddddddddddd": "not held", + "novox/web@aaaaaaaaaaaa novox/web@eeeeeeeeeeee": "no such delivery", + } { + if _, err := w.h.Settle(strings.Fields(ids), "on every machine since 10-25", aPerson); err == nil || !strings.Contains(err.Error(), words) { + t.Errorf("settle %s: %v, want refused naming %q", ids, err, words) + } + } + if _, err := w.h.Settle([]string{a.ID}, " ", aPerson); err == nil { + t.Error("a settle with no why was asked") + } + if w.op.count() != 0 { + t.Fatalf("a refused settle asked the operator: %d asks", w.op.count()) + } + out, err := w.h.Settle([]string{b.ID, a.ID}, "on every machine since 10-25", aPerson) + if err != nil { + t.Fatal(err) + } + key, _ := out["asked"].(string) + if open := w.op.open(); len(open) != 1 || open[0] != key || w.op.count() != 1 { + t.Fatalf("asks open %v (asked %d), want the one key %s", open, w.op.count(), key) + } + ask := w.op.ask(key) + heads := map[string]string{a.ID: a.Commit, b.ID: b.Commit} + group := sdk.SettleGroup([]string{a.ID, b.ID}) + for _, o := range ask.Options { + if want, _ := asks.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want { + t.Errorf("the option %s binds %s, not mesh-delivery.settle of these heads", o.ID, o.Binds) + } + } + w.h.hearAsks() + if a.State != Held || b.State != Held { + t.Fatalf("moved before an answer: %s, %s", a.State, b.State) + } + w.op.answer(key, OptionLeave) + w.h.hearAsks() + if a.State != Held || a.Settle == nil || a.Settle.Answer != OptionLeave { + t.Fatalf("after Leave: %s %+v", a.State, a.Settle) + } + // Asked again: a new asking of the same key, under its own id. + if _, err := w.h.Settle([]string{a.ID, b.ID}, "asked again", aPerson); err != nil { + t.Fatal(err) + } + if again := w.op.ask(key); again.ID == ask.ID || a.Settle.Asking != 2 { + t.Fatalf("asked again under %s (asking %d), want a new id", again.ID, a.Settle.Asking) + } + w.op.answer(key, OptionSettle) + w.h.hearAsks() + for _, d := range []*Delivery{a, b} { + last := d.Transitions[len(d.Transitions)-1] + if d.State != Merged || last.Event != EvSettled || last.By != "the operator" || !strings.Contains(last.Why, "chose Settle") { + t.Errorf("%s after Settle: %s, %+v", d.ID, d.State, last) + } + } +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go index f95563a0..bfc56be9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go @@ -77,8 +77,18 @@ const ( EvMerged Event = "merged" // it reached the trunk EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323) + EvSettled Event = "settled" // the operator settled a merge held without a passing check (novox/hq ADR 0556 clause 5) ) +// HeldUnchecked is the why of a delivery held because its merge reached the trunk without a passing check: the one hold a +// person may settle (novox/hq ADR 0556 clause 5). +const HeldUnchecked = "merged without a passing check: only a person decides what follows" + +// heldUnchecked is whether a held delivery is held for a merge without a passing check, and reached the trunk. +func heldUnchecked(d *Delivery) bool { + return d.State == Held && d.MergedAs != "" && strings.HasPrefix(d.HeldWhy, "merged without a passing check") +} + // Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act. type Facts struct { Now time.Time @@ -177,6 +187,20 @@ var Table = []Row{ {From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held, Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows", Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }}, + // **A person settles a merge held without a passing check** (novox/hq ADR 0556 clause 5): only on the operator's answer to + // the settle ask, with its words; a hold of another kind is not taken here. Whether it is on the machines is then derived. + {From: []State{Held}, Event: EvSettled, To: Merged, Act: true, + Guard: "held because its merge reached the trunk without a passing check, and the operator answered settle to the ask " + + "`settle` wrote, with the words of that answer", + Holds: func(d *Delivery, f Facts) error { + switch { + case !heldUnchecked(d): + return errors.New("it is not held for a merge without a passing check: " + d.HeldWhy) + case f.By == "" || f.Why == "": + return errors.New("a settle names the operator's answer and its words") + } + return nil + }}, {From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true, Guard: "a person asked, with why, or its group changed", Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }}, diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go index ab2bccbf..bf92f306 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table_test.go @@ -34,6 +34,9 @@ func aDeliveryFor(r Row) (*Delivery, Facts) { case EvTrunkMoved: d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"} d.TrunkNow = "690966dccccccccc" + case EvSettled: + d.MergedAs, d.HeldWhy = "aaaa1111", HeldUnchecked + f.Why, f.By = "settled: the operator chose Settle", "the operator" } return d, f } @@ -103,7 +106,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing. func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) { events := []Event{EvAnnounced, EvAccepted, EvRefused, EvRecheck, EvReask, EvLost, EvNewHead, EvClosed, EvMerged, - EvMergedUnchecked, EvTrunkMoved} + EvMergedUnchecked, EvTrunkMoved, EvSettled} for _, from := range append([]State{None}, AllStates...) { for _, ev := range events { for _, act := range []bool{false, true} { @@ -128,14 +131,17 @@ func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) { // The rules the table is for, read off the table. func TestTheTableKeepsItsRules(t *testing.T) { for _, r := range Table { + // The one row out of a final state is a person's settle of a merge held without a passing check, taken on the + // operator's answer (novox/hq ADR 0556 clause 5): it is already on the trunk, and moves from held to merged. + settle := r.Event == EvSettled && r.Act && len(r.From) == 1 && r.From[0] == Held && r.To == Merged for _, from := range r.From { - // Nothing leaves a final state. - if from.Final() { + // Nothing else leaves a final state. + if from.Final() && !settle { t.Errorf("%s is final and the table leaves it by %s", from, r.Event) } } // Only a ready delivery is merged, and nothing but its merge, or a merge without a passing check, puts one on the trunk. - if r.To == Merged && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) { + if r.To == Merged && !settle && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) { t.Errorf("%v —%s→ merged: only a ready delivery's merge merges", r.From, r.Event) } if r.To == Held && r.Event != EvMergedUnchecked { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go index 166832cc..07689592 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -22,6 +22,9 @@ type Line struct { Waits string `json:"waits,omitempty"` Owed int `json:"owed,omitempty"` Stalled bool `json:"stalled,omitempty"` + // Machines says, for a merged delivery, whether its merge is on the machines, derived from the delivery view's reach + // (reached.go, novox/hq ADR 0556 clause 4); recorded nowhere. + Machines string `json:"machines,omitempty"` } // waitsFor is what a delivery waits for, in words. @@ -61,7 +64,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line { continue } l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(), - Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed)} + Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed), Machines: h.machines(d)} l.Stalled = pastBound(d, now) out = append(out, l) } @@ -80,6 +83,9 @@ func (h *Holder) Show(id string) (any, error) { defer h.mu.Unlock() if d := h.deliveries[id]; d != nil { out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()} + if m := h.machines(d); m != "" { + out["machines"] = m + } if g := h.groups[d.Group]; g != nil { state, why, _ := h.groupState(g, h.membersOf(g)) out["group"] = map[string]any{"group": g, "state": state, "why": why} @@ -326,5 +332,9 @@ func (h *Holder) Status(listening string) map[string]any { // HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to // read; the gate moves nothing after the merge. func HeldLine(d *Delivery) string { - return fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy) + line := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy) + if heldUnchecked(d) { + line += " (mesh-delivery.settle asks the operator to settle it, ADR 0556)" + } + return line } diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index 6dd019be..06e16ec7 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -14,7 +14,8 @@ "table", "stalled", "recheck", - "close" + "close", + "settle" ] } ], @@ -70,7 +71,8 @@ ], "reads": [ "mesh-delivery-teller.answers", - "mesh-delivery-checker.verdicts" + "mesh-delivery-checker.verdicts", + "mesh-delivery-view.reach" ], "tools": [ "delivery_hand_now", -- 2.54.0 From eab69184598657431bb46d7960942d2c6b37c59b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:02:41 +0100 Subject: [PATCH 2/6] The tracker takes the view's per-repository reached head as it takes the whole-mesh one: a merge at or before it is delivered (issue 1280, ADR 0556 clause 6) --- .../cmd/mesh-issues/delivered_test.go | 19 +++++++++++++++++++ .../mesh-issues/cmd/mesh-issues/delivery.go | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/modules/mesh-issues/cmd/mesh-issues/delivered_test.go b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go index cea93929..22a05f06 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivered_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go @@ -130,6 +130,25 @@ func TestANewerRevisionCoversTheOlderMergesOfItsRepositoryAsTheForgeSaysAncestry if v, _ := h2.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#33" { t.Fatalf("another repository's head covered it: %q", v.Delivery) } + // 1280 (ADR 0556 clauses 3 and 6): the view's per-repository form, a repository's reached head with no group, delivers the + // merges at or before that head and not a later one. + h3 := newHarness(t) + reached, later := fixIssue(t, h3, "mesh-catalog#34"), fixIssue(t, h3, "mesh-catalog#35") + mergedFix(t, h3, 34, "feat/e", "6666666666660000") + mergedFix(t, h3, 35, "feat/f", "7777777777770000") + h3.Contains = func(_, _, commit, in string) (bool, error) { + return commit == "6666666666660000" && in == "8888888888880000", nil + } + raw, _ := json.Marshal(map[string]any{"revision": 15, "group": "", "repository": "novox/mesh-catalog", + "heads": map[string]string{"novox/mesh-catalog": "8888888888880000"}}) + if err := h3.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil { + t.Fatal(err) + } + for n, want := range map[uint64]string{reached: "delivered to every machine it should reach", later: "fix merged, not delivered: mesh-catalog#35"} { + if v, _ := h3.View(n); v.Delivery != want { + t.Errorf("per repository: issue %d reads %q, wanted %q", n, v.Delivery, want) + } + } } // A fix kept with only its merged flag (heard through `checks`, or kept before release 6) is matched to a delivered revision diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery.go b/modules/mesh-issues/cmd/mesh-issues/delivery.go index b09e1fc7..bd21bcfc 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery.go @@ -429,7 +429,8 @@ const DeliveredEvent = "mesh-delivery-view.delivered" // group moved, or whose merge is the head the revision holds for its repository, reached the machines at that revision. The same // revision twice changes nothing (a delivered fix stays delivered). A fix is matched by the group it merged in, by its own merge // commit, or because its merge is an ancestor of the head the revision holds for its repository (coveredBy): a newer revision -// covers the older ones, which the view may never have said. +// covers the older ones, which the view may never have said. The view also says each repository's reached head in this form, +// with no group and that repository's head alone (novox/hq ADR 0556 clause 3): a merge at or before it is on the machines. func (t *Tracker) OnDelivered(e stdio.Envelope) error { var body struct { Revision uint64 `json:"revision"` -- 2.54.0 From 671fdb7dc203612a5a2642ba1c5bd43a9b13d7db Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:05:29 +0100 Subject: [PATCH 3/6] Pin mesh-sdk 33144781 (reach and settle layouts) in the gate (issue 1280) --- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 3b7ee541..d219ce5f 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index 346bdcce..a8894095 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f h1:9+TZpNq8Ifj1+qmbidE6l/7qkbua4yLdzRMrezNvf1A= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652 h1:R+gkczO84KN1nbTG3ubIfAAkbx/jTb+txW1fQVhtmSI= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 9e41438f1b647fe97920bbd304242150f7197217 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:15:31 +0100 Subject: [PATCH 4/6] A merge of a module running nowhere countable reads not yet on the machines in the gate and the tracker; a settle ask is taken away once answered or lapsed; pin mesh-sdk e44cbaf (review of issue 1280) --- .../cmd/mesh-delivery-gate/approval.go | 1 + .../cmd/mesh-delivery-gate/reached.go | 14 +++++++ .../cmd/mesh-delivery-gate/reached_test.go | 8 ++++ .../cmd/mesh-delivery-gate/settle.go | 37 +++++++++++++++++++ .../cmd/mesh-delivery-gate/settle_test.go | 26 +++++++++++++ modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 +- .../cmd/mesh-issues/delivered_test.go | 16 ++++++-- .../mesh-issues/cmd/mesh-issues/delivery.go | 16 +++++++- 9 files changed, 116 insertions(+), 8 deletions(-) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go index d835ffb4..9e0f7e96 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -299,6 +299,7 @@ func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool { // §3), and says so once on each pull request it covered (ADR 0421 §6). An ask for a head a newer one replaced ends too. // A Decline stands, and is not ended. Under the lock; the cancels run with the look's asks. func (h *Holder) endStaleAsks() { + h.endStaleSettles() for _, d := range h.deliveries { r := d.Ready if r == nil || r.Merged != nil || d.MergedAs != "" || r.Declined != nil || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go index 5bf5fb37..6456be41 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached.go @@ -54,6 +54,20 @@ func (h *Holder) machines(d *Delivery) string { switch { case !ok: return notKnownWhere + "the delivery view says no reach of " + d.Repository + } + // A merge that changed a module a node wants and runs nothing countable of is not yet there, whatever the reached head + // (the module holds no head back, ADR 0556 clause 2). + for _, mr := range r.Modules { + if mr.Nodes == 0 || mr.Floor != "" { + continue + } + for _, c := range mr.Changes { + if sameCommit(d.MergedAs, c.Commit) { + return notYetOn + mr.Waiting + } + } + } + switch { case r.Reached == "": return notYetOn + holdsSaid(r) case sameCommit(d.MergedAs, r.Reached): diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go index 89493406..657a42eb 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/reached_test.go @@ -49,6 +49,14 @@ func TestAMergedDeliverySaysWhetherItIsOnTheMachines(t *testing.T) { if got := machines(first); got != notYetOn+"web on ace runs nothing yet" { t.Errorf("no reached head: %q", got) } + // A module a node wants and runs nothing countable of holds no head back, and a merge that changed it is not yet there. + reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9, Modules: map[string]sdk.ModuleReach{ + "web": {FP: "src2:x", Nodes: 1, Waiting: "web on g14 runs nothing yet", Changes: []sdk.Change{{Commit: second.MergedAs, FP: "src2:x"}}}}}) + for d, want := range map[*Delivery]string{first: onMachines, second: notYetOn + "web on g14 runs nothing yet", third: onMachines} { + if got := machines(d); got != want { + t.Errorf("with a module running nowhere countable, %s: %q, want %q", d.ID, got, want) + } + } // The verbs say it; a delivery not merged says nothing of the machines. reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9}) lines := w.h.Deliveries("", "novox/web", "", true) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go index 85263657..a95ef111 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle.go @@ -184,6 +184,7 @@ func (h *Holder) settled(key string, ans sdk.Answer) { } } if len(covered) == 0 { + h.Logf("[mesh-delivery] the answer to the settle ask %s covers no delivery that waits for it: nothing settled", key) return } sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID }) @@ -212,4 +213,40 @@ func (h *Holder) settled(key string, ans sdk.Answer) { d.HeldWhy = "" h.moved(d, []Transition{t}) } + // Answered: the ask is taken away, as a merge ask is once acted on. + h.cancels = append(h.cancels, h.cancelSettle(key)) +} + +// cancelSettle takes a settle ask away from the gate's asks. +func (h *Holder) cancelSettle(key string) func() { + return func() { + if h.Operator == nil { + return + } + if err := h.Operator.Cancel(key); err != nil { + h.Logf("[mesh-delivery] the settle ask %s could not be taken away (taken away again at the next look): %v", key, err) + } + } +} + +// endStaleSettles takes away every settle ask nobody answered within its bound; its deliveries stay held and settle may ask +// again. Under the lock; the cancels run with the look's asks. +func (h *Holder) endStaleSettles() { + done := map[string]bool{} + for _, d := range h.deliveries { + s := d.Settle + if s == nil || s.Answered != nil || done[s.Key] || h.Now().Before(s.At.Add(AskLasts)) { + continue + } + done[s.Key] = true + h.Logf("[mesh-delivery] the settle ask %s lapsed with no answer: taken away, %s stays held", s.Key, d.ID) + h.cancels = append(h.cancels, h.cancelSettle(s.Key)) + } + for _, d := range h.deliveries { + if s := d.Settle; s != nil && done[s.Key] { + now := h.Now() + s.Answered, s.Answer = &now, "lapsed" + h.keep(d) + } + } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go index 9ccb8cea..bbe88fae 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settle_test.go @@ -76,12 +76,38 @@ func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) { if again := w.op.ask(key); again.ID == ask.ID || a.Settle.Asking != 2 { t.Fatalf("asked again under %s (asking %d), want a new id", again.ID, a.Settle.Asking) } + // An answer under a settle key no delivery waits for moves nothing. + other, _ := sdk.MergeAskKey(sdk.SettleGroup([]string{"novox/web@ffffffffffff"}), []sdk.Head{{Repository: "novox/web", Number: 1, Head: "ffffffffffff"}}) + w.h.mu.Lock() + w.h.settled(other, sdk.Answer{Option: OptionSettle, Warrant: "x@now", Words: "chose Settle"}) + w.h.mu.Unlock() + if a.State != Held || b.State != Held || !strings.Contains(w.logbuf.String(), "covers no delivery") { + t.Fatalf("an answer for other ids moved %s, %s", a.State, b.State) + } w.op.answer(key, OptionSettle) w.h.hearAsks() + w.h.mu.Lock() + cancels := len(w.h.cancels) + w.h.mu.Unlock() + if cancels == 0 { + t.Error("the answered settle ask is not taken away") + } for _, d := range []*Delivery{a, b} { last := d.Transitions[len(d.Transitions)-1] if d.State != Merged || last.Event != EvSettled || last.By != "the operator" || !strings.Contains(last.Why, "chose Settle") { t.Errorf("%s after Settle: %s, %+v", d.ID, d.State, last) } } + // A settle ask nobody answers within its bound is taken away; its delivery stays held. + c := put("novox/web@999999999999", Held, HeldUnchecked) + if _, err := w.h.Settle([]string{c.ID}, "once more", aPerson); err != nil { + t.Fatal(err) + } + w.later(AskLasts + time.Minute) + w.h.mu.Lock() + w.h.endStaleSettles() + w.h.mu.Unlock() + if c.State != Held || c.Settle.Answer != "lapsed" { + t.Errorf("a lapsed settle ask: %s %+v", c.State, c.Settle) + } } diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index d219ce5f..1a02ef85 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index a8894095..73a8b6b4 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652 h1:R+gkczO84KN1nbTG3ubIfAAkbx/jTb+txW1fQVhtmSI= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026195749-33144781d652/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3 h1:kOAR8GaTpOP9j2XNm3grqEfSwSDHnWbHKd+Qftkr/QE= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-issues/cmd/mesh-issues/delivered_test.go b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go index 22a05f06..67a810be 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivered_test.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivered_test.go @@ -139,11 +139,19 @@ func TestANewerRevisionCoversTheOlderMergesOfItsRepositoryAsTheForgeSaysAncestry h3.Contains = func(_, _, commit, in string) (bool, error) { return commit == "6666666666660000" && in == "8888888888880000", nil } - raw, _ := json.Marshal(map[string]any{"revision": 15, "group": "", "repository": "novox/mesh-catalog", - "heads": map[string]string{"novox/mesh-catalog": "8888888888880000"}}) - if err := h3.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil { - t.Fatal(err) + say := func(rev int, notYet ...string) { + raw, _ := json.Marshal(map[string]any{"revision": rev, "group": "", "repository": "novox/mesh-catalog", + "heads": map[string]string{"novox/mesh-catalog": "8888888888880000"}, "not_yet": notYet}) + if err := h3.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil { + t.Fatal(err) + } } + // A merge the event says is not yet on the machines (its module runs on no node yet) waits for the next event. + say(15, "6666666666660000") + if v, _ := h3.View(reached); v.Delivery != "fix merged, not delivered: mesh-catalog#34" { + t.Errorf("a merge said not yet: %q", v.Delivery) + } + say(15) for n, want := range map[uint64]string{reached: "delivered to every machine it should reach", later: "fix merged, not delivered: mesh-catalog#35"} { if v, _ := h3.View(n); v.Delivery != want { t.Errorf("per repository: issue %d reads %q, wanted %q", n, v.Delivery, want) diff --git a/modules/mesh-issues/cmd/mesh-issues/delivery.go b/modules/mesh-issues/cmd/mesh-issues/delivery.go index bd21bcfc..75812f9f 100644 --- a/modules/mesh-issues/cmd/mesh-issues/delivery.go +++ b/modules/mesh-issues/cmd/mesh-issues/delivery.go @@ -438,6 +438,9 @@ func (t *Tracker) OnDelivered(e stdio.Envelope) error { Heads map[string]string `json:"heads"` // At is when the view judged the revision delivered, when it says so; else this tracker's moment of hearing it. At time.Time `json:"at"` + // NotYet are, in the per-repository form, merges at or before the head that are not yet on the machines (a module a node + // wants and runs nothing countable of, ADR 0556 clause 2): a fix merged as one of them waits for a later event. + NotYet []string `json:"not_yet"` } if err := json.Unmarshal(e.Body, &body); err != nil || (body.Group == "" && len(body.Heads) == 0) { return nil @@ -457,7 +460,8 @@ func (t *Tracker) OnDelivered(e stdio.Envelope) error { if f != nil && f.Merged && !contains(is.Delivered, pr) && f.MergedAs == "" { t.fillMerge(n, f, pr) } - if f == nil || !f.Merged || contains(is.Delivered, pr) || !(deliveredBy(f, body.Group, body.Heads) || t.coveredBy(f, body.Heads)) { + if f == nil || !f.Merged || contains(is.Delivered, pr) || notYet(f, body.NotYet) || + !(deliveredBy(f, body.Group, body.Heads) || t.coveredBy(f, body.Heads)) { continue } pr := pr @@ -585,6 +589,16 @@ func deliveredBy(f *Fix, group string, heads map[string]string) bool { return false } +// notYet is whether a fix's merge is among the merges an event says are not yet on the machines. +func notYet(f *Fix, merges []string) bool { + for _, m := range merges { + if f.MergedAs != "" && sameCommitIn(f.MergedAs, m) { + return true + } + } + return false +} + // sameCommitIn is whether two commits are one, however either is abbreviated (at least seven characters). func sameCommitIn(a, b string) bool { a, b = strings.ToLower(a), strings.ToLower(b) -- 2.54.0 From 234d2d55343ba2af2f03a66944598fcee37f10ff Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 22:11:55 +0100 Subject: [PATCH 5/6] mesh-delivery-gate: pin mesh-sdk b4310df, the delivery-records work merged with sdk main (issue 1280) --- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index 1a02ef85..c5acb0be 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index 73a8b6b4..64b301b0 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3 h1:kOAR8GaTpOP9j2XNm3grqEfSwSDHnWbHKd+Qftkr/QE= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026201248-e44cbaf57fc3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6 h1:JDgD6KA80fzQzVj/NFQD7hXVsZQfsYW6xjZp0MoPDUU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0 From 4677b8a2f50189bcb5c28f39977ad484a74e1db5 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 22:21:21 +0100 Subject: [PATCH 6/6] mesh-delivery-gate: pin mesh-sdk 68d65e6, the group's sdk head merged with sdk main (issue 1280) --- modules/mesh-delivery-gate/go.mod | 2 +- modules/mesh-delivery-gate/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-gate/go.mod b/modules/mesh-delivery-gate/go.mod index c5acb0be..aee248e7 100644 --- a/modules/mesh-delivery-gate/go.mod +++ b/modules/mesh-delivery-gate/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-gate go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-gate/go.sum b/modules/mesh-delivery-gate/go.sum index 64b301b0..b3dab4f0 100644 --- a/modules/mesh-delivery-gate/go.sum +++ b/modules/mesh-delivery-gate/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6 h1:JDgD6KA80fzQzVj/NFQD7hXVsZQfsYW6xjZp0MoPDUU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210442-b4310dfb8ec6/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 h1:/c6ncICWjq55nBf8TlDM0ROe/PuH+qZIdnuVbtg8UuM= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -- 2.54.0