Merge pull request 'Gate: hand every member of an approved group in pin order, settle pull requests closed on the forge, and say a stopped pass as a condition (issue 1275)' (#480) from fix/1275-group-hands-every-member into main

Reviewed-on: #480
This commit was merged in pull request #480.
This commit is contained in:
2026-10-25 19:28:33 +00:00
18 changed files with 887 additions and 75 deletions
+2 -2
View File
@@ -508,11 +508,11 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string } | undefined;
if (number > 0) {
const p = await gitea.getPullRequest(owner, repo, number);
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url };
if (!ref) ref = p.head_sha ?? "";
if (!base) base = p.base ?? "";
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
+4 -1
View File
@@ -22,7 +22,10 @@ is gone is read as the state it came to (merged, closed or proposed) when the ga
## When a refused or rejected merge is said (ADR 0505 §4, §5)
`stalled` is what the controller raises as a condition for the operator. The gate's own list (`show`, `deliveries`)
`stalled` is what the gate says to the operator as conditions, itself, on the operator-channel seat (ADR 0437 §9, issue
1275): after every look it raises each line, raises it again when its severity or need changes, and clears each that is
listed no more; its bucket `conditions` keeps which it raised. (The controller's self-check that raised them went in
release 6.) The gate's own list (`show`, `deliveries`)
names every refusal and rejection at once, and the pull request is told at once; only `stalled` waits:
- a merge refused because its trunk moved, whose check the gate asked again, is stalled only once that check answered and
@@ -0,0 +1,229 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The gate says its own stalled lines to the operator as conditions (novox/hq issue 1275, met again 2026-10-25; ADR 0437
// §9: any module says a condition on the operator-channel seat's one subject, and the router alone chooses the channels).
// Until release 6 the controller's self-check read the `stalled` verb and raised each line as `delivery.<id>.stalled`;
// release 6 deleted that self-check (mesh-controller dc456d56) and nothing read the verb since, so a half-merged group was
// said to no one. Now, after every look, the gate raises each line `stalled` lists, raises it again when its severity or
// its need changes, and clears each it raised that is listed no more. What it raised is kept in its bucket conditions, so a
// gate that restarts clears what ended while it was away.
// ConditionStore keeps the conditions the gate has raised: the condition's key to what it said (conditionSaid).
type ConditionStore interface {
Said() (map[string]string, error)
PutSaid(said map[string]string) error
}
// saidKey is the one key of the bucket conditions.
const saidKey = "said"
// kvConditions is the bucket conditions over the runtime.
type kvConditions struct{}
func (kvConditions) Said() (map[string]string, error) {
e, err := stdio.State("conditions").Get(saidKey)
if err != nil || e == nil {
return map[string]string{}, err
}
said := map[string]string{}
if err := json.Unmarshal(e.Value, &said); err != nil {
return nil, fmt.Errorf("the conditions the gate raised are not readable: %w", err)
}
return said, nil
}
func (kvConditions) PutSaid(said map[string]string) error {
_, err := stdio.State("conditions").Put(saidKey, said)
return err
}
// sayOnSeat says a condition on the operator-channel seat under the gate's own name.
func sayOnSeat(c asks.Condition) error {
subject, err := asks.ConditionSubject("mesh-delivery-gate")
if err != nil {
return err
}
raw, err := asks.EncodeCondition(c)
if err != nil {
return err
}
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
}
// conditionKeyOf is the key of a stalled line's condition: its last part is the condition's kind.
func conditionKeyOf(id string) string { return "mesh-delivery-gate.delivery." + id + ".stalled" }
// conditionSaid is what a raised condition said that, when it changes, raises it again: its severity and what it needs.
// The words of a line hold durations, which change at every look and are no reason to tell the operator again.
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 + "|" + l.Needs }
func severityOfLine(l StalledLine) string {
if l.Severity == asks.SeverityUrgent {
return asks.SeverityUrgent
}
return asks.SeverityWarning
}
// conditionOf is a stalled line as the condition the operator is told.
func conditionOf(l StalledLine) asks.Condition {
headline := "stalled: " + l.ID
if strings.HasPrefix(l.Says, "group ") {
group, _, _ := strings.Cut(strings.TrimPrefix(l.Says, "group "), " ")
headline = "group " + group + " is not merged whole"
}
if r := []rune(headline); len(r) > asks.HeadlineLength {
headline = string(r[:asks.HeadlineLength-1]) + "…"
}
needs := "read mesh-delivery.show " + l.ID + " and decide"
return asks.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: asks.StateRaised,
Subject: asks.Scope{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
Explanation: asks.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
Resolved: "The gate lists " + l.ID + " as stalled no more."}
}
// sayConditions raises and clears the conditions of the stalled lines (see above). Outside the lock; React runs it after
// the look, so it follows every event. A condition that could not be said is said again at the next look, after
// askAgainAfter at the latest.
func (h *Holder) sayConditions() {
if h.Say == nil || h.Conditions == nil {
return
}
if h.said == nil {
said, err := h.Conditions.Said()
if err != nil {
h.Logf("[mesh-delivery] the conditions raised before cannot be read, so none is said yet: %v; asked again in %s", err, askAgainAfter)
h.again("the conditions the gate raised, read")
return
}
h.said = said
}
h.mu.Lock()
if at := h.nextStall(h.Now()); !at.IsZero() {
h.lookAt(at, "a delivery whose stalled line, or its severity, comes due with no event")
}
forgeRead := h.forgeRead
waitingOnForge := map[string]bool{}
for id := range h.unchecked {
waitingOnForge[id] = true
}
h.mu.Unlock()
lines := map[string]StalledLine{}
for _, l := range h.Stalled() {
// One condition for an id: the most severe of its lines.
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != asks.SeverityUrgent && severityOfLine(l) == asks.SeverityUrgent) {
lines[l.ID] = l
}
}
ids := make([]string, 0, len(lines))
for id := range lines {
ids = append(ids, id)
}
sort.Strings(ids)
changed, failed := false, false
for _, id := range ids {
l, key := lines[id], conditionKeyOf(id)
said := conditionSaid(l)
if h.said[key] == said {
continue
}
if err := h.Say(conditionOf(l)); err != nil {
h.Logf("[mesh-delivery] the condition of %s could not be said: %v", id, err)
failed = true
continue
}
h.Logf("[mesh-delivery] %s is said to the operator as %s: %s", id, severityOfLine(l), l.Says)
h.said[key], changed = said, true
}
// Cleared only on what was read: not before the forge's open pull requests were read once since the gate started (the
// heads waiting on the forge are kept in memory alone), and not while the forge still shows the head open and waiting:
// its line is gone only because its bound runs again from a later first sight, and it comes back with the same words.
var gone []string
for key := range h.said {
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
if _, ok := lines[id]; !ok && forgeRead && !waitingOnForge[id] {
gone = append(gone, key)
}
}
sort.Strings(gone)
for _, key := range gone {
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
c := asks.Condition{Key: key, Subject: asks.Scope{Scope: "delivery", ID: id}, State: asks.StateCleared,
Resolved: "The gate lists " + id + " as stalled no more."}
if err := h.Say(c); err != nil {
h.Logf("[mesh-delivery] the condition of %s could not be cleared: %v", id, err)
failed = true
continue
}
h.Logf("[mesh-delivery] the condition of %s is cleared: it is stalled no more", id)
delete(h.said, key)
changed = true
}
if changed {
if err := h.Conditions.PutSaid(h.said); err != nil {
h.Logf("[mesh-delivery] the conditions raised cannot be kept: %v", err)
}
}
if !failed {
h.sayFailures = 0
return
}
// A seat that keeps refusing is tried again after askAgainAfter, up to sayTries looks in a row; then only at the
// next event, and said once, loudly, in the log (the operator hears nothing while the seat refuses).
h.sayFailures++
switch {
case h.sayFailures < sayTries:
h.again("a condition that could not be said to the operator")
case h.sayFailures == sayTries:
h.Logf("[mesh-delivery] NO CONDITION REACHES THE OPERATOR: the operator-channel seat refused %d looks in a row; "+
"tried again at each event from now on", sayTries)
}
}
// sayTries is how many looks in a row a condition that could not be said is tried again on the clock.
const sayTries = 10
// nextStall is the earliest time after now when a delivery's stalled line comes due, or its severity changes, with nothing
// else happening: a state's bound, the author's bound of a rejected head, a head never announced, a refusal the gate
// answers, and an out-of-date refusal turning urgent (1275/AC6). Zero when none. Under the lock.
func (h *Holder) nextStall(now time.Time) time.Time {
var next time.Time
consider := func(at time.Time) {
if at.After(now) && (next.IsZero() || at.Before(next)) {
next = at
}
}
for _, d := range h.deliveries {
if d.State.Final() {
continue
}
if b, bounded := boundOf(d); bounded && b.For > 0 {
consider(boundSince(d).Add(b.For).Add(time.Second)) // pastBound is strictly after the bound
}
r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" {
continue
}
if d.Base == "" && d.Number != 0 {
consider(r.Seen.Add(headHeardBound))
}
if r.Failed != "" && d.State == Rejected {
consider(d.Since.Add(h.authorBound()))
}
if r.RefusedSince != nil {
consider(r.RefusedSince.Add(h.answeredRefusalBound(d.Repository)))
consider(r.RefusedSince.Add(outOfDateFor))
}
}
return next
}
@@ -63,7 +63,8 @@ func (h *Holder) unmergedLive(g *Group) []*Delivery {
// groupMergeable reads every member's required statuses at the head the Approve covers, and says whether all are a success.
// When one is not, or cannot be read, nothing is handed to the bridge: the group is held, said urgent (ADR 0441 §2, §4), and
// the target's turn is given back so the next event that decides whether it may merge asks it again. Outside the lock.
// the target's turn is given back so the next event that decides whether it may merge asks it again. A read that could not
// be made is a failed ask, tried again after askAgainAfter. Outside the lock.
func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
release := func() {
if d := h.deliveries[target]; d != nil && d.Ready != nil {
@@ -98,8 +99,9 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
h.keepGroup(g)
}
// No timer: the status turning success is an event on the forge (CommitStatus, mergeAgainNow), and it asks the
// merge again at once (issue 1275: a pass that stops part way resumes on an event).
release()
h.again("a group's merge held for a required status")
h.mu.Unlock()
return false
}
@@ -112,7 +114,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
return true
}
// groupLines are the groups that are held or half merged, as urgent stalled lines the controller raises as conditions:
// groupLines are the groups that are held or half merged, as urgent stalled lines the gate says as conditions (conditions.go):
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
// group half merged with no refusal. Under the lock.
@@ -141,12 +143,16 @@ func (h *Holder) groupLines(now time.Time) []StalledLine {
switch {
// A held member the merge tool refused is said by readyLines already: one line for an id, not two.
case g.Hold != nil && g.Hold.Kind == "blocked" && !running && !(heldOn != nil && heldOn.Ready != nil && heldOn.Ready.RefusedSince != nil):
stands := "is approved and nothing is merged"
if merged > 0 {
stands = "is half merged" // a member merged before the pass stopped (issue 1275)
}
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
H2: "none: the required status turns success, which asks the merge again — or the operator's decision",
Says: fmt.Sprintf("group %s is approved and nothing is merged (merged %d of %d): %s waits on a required status "+
Says: fmt.Sprintf("group %s %s (merged %d of %d): %s waits on a required status "+
"at its head: %s. Needed: that status turns success (its check asked again), or the operator decides.",
g.ID, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
g.ID, stands, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
case merged > 0 && !refused && !running:
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
For: now.Sub(g.Updated).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
@@ -21,25 +21,44 @@ const (
// approvedGroup is a group of two pull requests on one branch, checks green, composed green and the operator's Approve
// asked and not yet answered (w.op.auto is off).
func approvedGroup(t *testing.T) *world {
return approvedGroupOf(t, member{gCtl, 1, gHc, "internal/a.go"}, member{gCat, 2, gHk, "modules/app/module.json"})
}
// member is a pull request of a group in feat/g: its repository, number, head and the paths it changes.
type member struct {
repo string
number int
head string
paths string
}
// approvedGroupOf is a group feat/g of these members, each checked, composed together and asked once.
func approvedGroupOf(t *testing.T, ms ...member) *world {
t.Helper()
w := newWorld(t)
w.op.auto = false
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
for _, m := range ms {
w.h.PullUpdated(pr(m.repo, m.number, m.head, "feat/g", strings.Split(m.paths, ",")...))
}
for _, m := range ms {
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
}
w.settleAll()
g := w.h.groups["feat/g"]
if g == nil || g.Check == nil {
t.Fatalf("no composed check was asked: %+v", g)
}
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
Members: []struct {
c := CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
for _, m := range ms {
owner, repo, _ := strings.Cut(m.repo, "/")
c.Members = append(c.Members, struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Commit string `json:"commit"`
Trunk string `json:"trunk,omitempty"`
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
}
w.h.Checked(c)
w.settleAll()
if w.op.count() != 1 || len(w.op.open()) != 1 {
t.Fatalf("the green group is asked once: %d asks, open %v", w.op.count(), w.op.open())
@@ -10,6 +10,8 @@ import (
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47): the one writer of deliveries and
@@ -37,6 +39,15 @@ type Holder struct {
Owed OwedStore
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
Pulls PullKeys
// Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none.
Say func(c asks.Condition) error
Conditions ConditionStore
// said is what Conditions keeps, read at the first look; sayFailures the looks in a row a condition could not be
// said. Touched by React alone.
said map[string]string
sayFailures int
// forgeRead is whether the forge's open pull requests were read since this owner started (SeeOpenPulls).
forgeRead bool
// cancels are asks to take back, gathered under the lock and run with the other asks of the look.
cancels []func()
@@ -635,7 +646,12 @@ func (h *Holder) leaveGroup(d *Delivery) {
// PullMerged takes a merge: the commit it landed as, and the delivery it was — made from the forge's word
// when this owner never heard its head (merged before it held the seat).
func (h *Holder) PullMerged(p PullEvent) {
func (h *Holder) PullMerged(p PullEvent) { h.pullMerged(p, false) }
// pullMerged takes a merge. late is a merge learnt after the fact (settleGone): its own delivery is merged and its group
// closed, but no other delivery's trunk moves (the trunk has long moved past it, by merges heard as they happened),
// its time is the forge's or none (never now), and it never moves source.main (LearntLate).
func (h *Holder) pullMerged(p PullEvent, late bool) {
if p.MergeCommit == "" {
h.Refuse(errors.New("a merge announced with no merge commit; nothing published"))
return
@@ -710,16 +726,24 @@ func (h *Holder) PullMerged(p PullEvent) {
d.MergedAs = p.MergeCommit
if t, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
d.MergedAt = t.UTC()
} else {
} else if !late {
d.MergedAt = now
}
if late {
d.LearntLate, d.InSource = true, true
if d.Ready != nil {
d.Ready.SourceMoved = true
}
}
ts = append(ts, settle(d, Facts{Now: now})...)
if d.State == Held && d.HeldWhy == "" {
d.HeldWhy = "merged without a passing check: only a person decides what follows"
}
h.moved(d, ts)
h.closeGroupOf(d)
h.trunkMoved(d, p.Base, p.MergeCommit, now)
if !late {
h.trunkMoved(d, p.Base, p.MergeCommit, now)
}
}
// trunkMoved takes a merge into a trunk as that trunk's move (novox/hq ADR 0323 §7): every other open delivery into
@@ -921,7 +945,7 @@ func (h *Holder) askChecks(now time.Time) func() {
}
}
// membersOf are a group's current members, by id order.
// membersOf are a group's current members, in the order they merge (mergeRank, then id).
func (h *Holder) membersOf(g *Group) []*Delivery {
var out []*Delivery
for _, id := range g.Members {
@@ -929,9 +953,39 @@ func (h *Holder) membersOf(g *Group) []*Delivery {
out = append(out, d)
}
}
sort.SliceStable(out, func(i, j int) bool {
if ri, rj := mergeRank(out[i]), mergeRank(out[j]); ri != rj {
return ri < rj
}
return out[i].ID < out[j].ID
})
return out
}
// SDKRepository is the repository of mesh-sdk, the library the other repositories' modules pin by commit (novox/hq ADR 0482 §3).
const SDKRepository = "novox/mesh-sdk"
// mergeRank is a member's place in its group's merge (novox/hq issue 1275): a member another member pins merges before it, so
// that the trunk never holds a pin of a commit not on its own trunk. The gate knows a pin by the paths a pull request changes:
// mesh-sdk's member goes first (the members of the other repositories pin it, ADR 0482 §3); then the members that change no
// go.mod or go.sum (they pin nothing new); then those that do (they pin another member's commit). The id orders a tie. The
// module graph is the fuller source (which module requires which), and is not the gate's to read: this is what its own
// deliveries say.
func mergeRank(d *Delivery) int {
if strings.EqualFold(d.Repository, SDKRepository) {
return 0
}
if d.PathsTruncated || len(d.Paths) == 0 {
return 2 // its paths are not all known: it may pin, so it goes after those that do not
}
for _, p := range d.Paths {
if base := p[strings.LastIndex(p, "/")+1:]; base == "go.mod" || base == "go.sum" {
return 2
}
}
return 1
}
// planGroup is what a group needs asked, and its turns.
func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func() {
var asks []func()
@@ -956,7 +1010,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
if held {
status = "pending" // no Approve exists while a member waits for its review (novox/hq ADR 0496 §2)
}
if state == "merging" && !(g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
if state == GroupMerging && !(g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
sameMembers(g.Check.Commits, commitsOf(members)) && g.Check.OutOfDate == "") {
// Success there lets the merge tool merge its members: only for a composed check that passed for these heads.
status = "pending"
@@ -980,6 +1034,23 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
return asks
}
// The words GroupState says a group's state with (ADR 0539 §17). Code that decides on a group's state compares with these,
// never with a literal: a word GroupState no longer says ("delivering", retired in release 6) left the second member of
// every approved group waiting for ever (novox/hq issue 1275, met again 2026-10-25). groupWords lists them all, and a test
// holds every decision on a group's state to that list.
const (
GroupChecking = "checking"
GroupRejected = "rejected"
GroupReady = "ready"
GroupMerging = "merging"
GroupMerged = "merged"
GroupHeld = "held"
GroupClosed = "closed"
)
// groupWords are every word GroupState says.
var groupWords = []string{GroupChecking, GroupRejected, GroupReady, GroupMerging, GroupMerged, GroupHeld, GroupClosed}
// GroupState is a group's state, derived from its members and its composed check — never set apart (novox/hq
// ADR 0239 decision 4, cut to the merge's states by ADR 0539 §17): checking, rejected, ready, merging (some members
// merged), merged, held and closed. Nothing after the merge is derived here: whether a merged change reached the machines is
@@ -992,36 +1063,36 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
n := len(members)
switch {
case count[Held] > 0 && g.Closed:
return "held", "a member is held: " + heldWords(members)
return GroupHeld, "a member is held: " + heldWords(members)
case count[Closed] > 0 && g.Closed:
return "closed", "a member was closed before the group merged"
return GroupClosed, "a member was closed before the group merged"
case n > 0 && count[Merged] == n:
return "merged", "every member is merged"
return GroupMerged, "every member is merged"
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
!sameMembers(g.Check.Commits, commitsOf(members))):
// A member rebased or pushed once the group was merging, or its composed check out of date: it is composed
// again for these heads, and the group is checking until then, never held (novox/hq ADR 0323 §6, issue 406).
if g.Check.OutOfDate != "" {
return "checking", g.Check.OutOfDate
return GroupChecking, g.Check.OutOfDate
}
return "checking", "composed again for its members' new heads"
return GroupChecking, "composed again for its members' new heads"
case g.Closed && g.Check != nil && g.Check.Verdict != "pass" && g.Check.Verdict != "warning":
// Composed again for new heads once it was merging, and those do not compose: its members wait (issue 902).
return "rejected", "composed together: " + g.Check.Summary
return GroupRejected, "composed together: " + g.Check.Summary
case g.Closed:
return "merging", fmt.Sprintf("%d of %d merged", count[Merged], n)
return GroupMerging, fmt.Sprintf("%d of %d merged", count[Merged], n)
case count[Rejected] > 0:
return "rejected", "a member's own check failed"
return GroupRejected, "a member's own check failed"
case g.Check != nil && g.Check.OutOfDate != "":
// Out of date counts as outstanding (ADR 0239 §18, novox/hq ADR 0323 §7): checking, saying why.
return "checking", g.Check.OutOfDate
return GroupChecking, g.Check.OutOfDate
case g.Check != nil && g.Check.Verdict != "" && g.Check.Verdict != "pass" && g.Check.Verdict != "warning":
return "rejected", "composed together: " + g.Check.Summary
return GroupRejected, "composed together: " + g.Check.Summary
case count[Ready] == n && g.Check != nil && sameMembers(g.Check.Commits, commitsOf(members)) &&
(g.Check.Verdict == "pass" || g.Check.Verdict == "warning"):
return "ready", "every member ready, and composed together they pass"
return GroupReady, "every member ready, and composed together they pass"
}
return "checking", fmt.Sprintf("%d of %d member(s) ready", count[Ready], n)
return GroupChecking, fmt.Sprintf("%d of %d member(s) ready", count[Ready], n)
}
// heldWords says the held members and why.
@@ -1040,7 +1111,7 @@ func heldWords(members []*Delivery) string {
// says its mesh/delivery-group is pending, not a success. Under the lock.
func (h *Holder) groupState(g *Group, members []*Delivery) (state, why string, held bool) {
state, why = GroupState(g, members)
if state != "ready" || g.Closed {
if state != GroupReady || g.Closed {
return state, why, false
}
var waiting []string
@@ -1063,11 +1134,11 @@ func (h *Holder) groupState(g *Group, members []*Delivery) (state, why string, h
// heads (novox/hq ADR 0382), and a pending one there would hold them for ever (novox/hq issue 902).
func groupStatusState(s string) string {
switch s {
case "ready", "merging", "merged":
case GroupReady, GroupMerging, GroupMerged:
return "success"
case "rejected":
case GroupRejected:
return "failure"
case "held", "closed":
case GroupHeld, GroupClosed:
return "error"
}
return "pending"
@@ -2,9 +2,14 @@ package main
import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
const time30s = 31 * time.Second
@@ -75,24 +80,6 @@ func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing
}
}
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
// naming the group, the refused member and the forge's words.
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
w := approvedGroup(t)
allGreen(w)
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
}
lines := urgentLines(w)
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
t.Fatalf("not said as one urgent line: %+v", lines)
}
}
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
// the second is merged; then it goes.
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
@@ -344,3 +331,404 @@ func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
}
}
// fakeConditions is the gate's bucket conditions and the operator-channel seat's subject for conditions: what it keeps,
// and every condition said, in order.
type fakeConditions struct {
mu sync.Mutex
kept map[string]string
told []asks.Condition
}
func (f *fakeConditions) Said() (map[string]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := map[string]string{}
for k, v := range f.kept {
out[k] = v
}
return out, nil
}
func (f *fakeConditions) PutSaid(said map[string]string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.kept = map[string]string{}
for k, v := range said {
f.kept[k] = v
}
return nil
}
func (f *fakeConditions) say(c asks.Condition) error {
if _, err := asks.EncodeCondition(c); err != nil {
return err // what the router would refuse
}
f.mu.Lock()
defer f.mu.Unlock()
f.told = append(f.told, c)
return nil
}
// last is the newest condition said for a delivery id, or nil.
func (f *fakeConditions) last(id string) *asks.Condition {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.told) - 1; i >= 0; i-- {
if f.told[i].Subject.ID == id {
c := f.told[i]
return &c
}
}
return nil
}
// hearConditions has the gate say its conditions into a fake seat and bucket.
func hearConditions(w *world) *fakeConditions {
f := &fakeConditions{kept: map[string]string{}}
w.h.Say, w.h.Conditions = f.say, f
return f
}
// handedInOrder answers each member the gate hands the forge bridge as the forge does on 2026-10-25: the forge's word that
// it merged (gitea.pull.merged) first, the bridge's word after; or the bridge's first, as forgeFirst says. It returns the
// repositories in the order they were handed.
func handedInOrder(w *world, forgeFirst bool) []string {
var handed []string
numbers := map[string]int{}
for i := 0; i < 4; i++ {
var next *sdk.MergePull
for _, p := range w.merges.last("feat/g").Pulls {
if p.State == sdk.MergeMerging {
p := p
next = &p
}
}
if next == nil || numbers[next.Repository] > 0 {
break
}
handed = append(handed, next.Repository)
numbers[next.Repository] = next.Number
mergeCommit := strings.Repeat(string(rune('a'+i)), 40)
forge := func() { w.h.PullMerged(merged(next.Repository, next.Number, next.Head.Head, mergeCommit)) }
bridge := func() { bridgeMerged(w, next.Repository, next.Number, next.Head.Head, mergeCommit) }
if forgeFirst {
forge()
w.settleAll() // ten seconds pass between the two words on 2026-10-25: the gate looks in between
bridge()
} else {
bridge()
w.settleAll()
forge()
}
w.settleAll()
}
return handed
}
// Issue 1275, met again 2026-10-25 (group fix/puller-wakes-on-capacity): every member of an approved group is handed to
// the forge bridge, in the group's order, the next on the event that the one before merged; a group whose pass stops is
// said to the operator as a condition, which is cleared once it is whole; and every word a group's state is said in is
// one the merge decides on.
func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
const (
sdkRepo, hS = "novox/mesh-sdk", "a8fbabbfc37369f9d1f6977c72e5523aff3d494c"
catRepo, hK = "novox/mesh-catalog", "23c827e7910c233608af4e4a8056cbc362dcb755"
ctlRepo, hC = "novox/mesh-controller", "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0"
)
sdkMember := member{sdkRepo, 80, hS, "go/stdio.go,go/buildstep/pull.go"}
catMember := member{catRepo, 479, hK, "modules/mesh-build-go/asks.go,modules/mesh-build-go/go.mod,modules/mesh-build-go/go.sum"}
ctlMember := member{ctlRepo, 7, hC, "cmd/mesh-controller/main.go"}
inOrder := func(members []member, forgeFirst bool, want ...string) func(t *testing.T) {
return func(t *testing.T) {
w := approvedGroupOf(t, members...)
w.merges.manual = true
allGreenFor(w, members...)
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if got := handedInOrder(w, forgeFirst); strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("handed %v, wanted %v, every member in the group's order", got, want)
}
if g := w.h.groups["feat/g"]; g == nil {
t.Fatal("the group is gone")
} else if state, why := GroupState(g, w.h.membersOf(g)); state != GroupMerged {
t.Fatalf("the group is %s (%s), not merged whole", state, why)
}
if w.source.main == nil || len(w.source.main.Heads) != len(want) {
t.Fatalf("source.main does not hold the group as one revision: %+v", w.source.main)
}
}
}
cases := []struct {
name string
run func(t *testing.T)
}{
// 1275/AC3 (2026-10-25): the case of the day. The catalogue member pins the sdk member's commit; the forge's word
// of a merge arrives before the bridge's. Before the fix the catalogue member went first, and the sdk member was
// never handed: its group read merging, a word the turn to merge did not know.
{"sdk first, then the member that pins it, the forge's word first", inOrder([]member{catMember, sdkMember}, true, sdkRepo, catRepo)},
{"sdk first, then the member that pins it, the bridge's word first", inOrder([]member{sdkMember, catMember}, false, sdkRepo, catRepo)},
{"a member that pins nothing before one that changes a go.mod", inOrder([]member{catMember, ctlMember}, true, ctlRepo, catRepo)},
{"three members", inOrder([]member{ctlMember, catMember, sdkMember}, true, sdkRepo, ctlRepo, catRepo)},
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said
// urgent, naming the group, the refused member and the forge's words, as a stalled line and to the operator.
{"a refused first member stops the group and is said urgent", func(t *testing.T) {
w := approvedGroup(t)
told := hearConditions(w)
allGreen(w)
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
}
lines := urgentLines(w)
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
t.Fatalf("not said as one urgent line: %+v", lines)
}
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent ||
!strings.Contains(c.Summary, "feat/g") {
t.Fatalf("the operator was not told, urgent: %+v", c)
}
}},
// 1275/AC4: a group half merged (its pass stopped after the first member) raises one urgent condition, said once
// however many looks follow, and cleared once the group is whole.
{"a half-merged group is raised once and cleared once whole", func(t *testing.T) {
w := approvedGroup(t)
told := hearConditions(w)
allGreen(w)
w.merges.manual = true
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
bridgeMerged(w, gCat, 2, gHk, gT1)
setChecks(w, gCtl, gHc, "success", "") // the second member's status went missing: the pass stops at it
w.settleAll()
for range 3 {
w.later(time30s)
w.settleAll()
}
id := IDOf(gCtl, gHc)
raised := 0
for _, c := range told.told {
if c.Subject.ID == id && c.State == asks.StateRaised {
raised++
}
}
if c := told.last(id); raised != 1 || c == nil || c.Severity != asks.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
}
setChecks(w, gCtl, gHc, "success", "success")
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
w.settleAll() // the status is an event: the pass goes on with no timer
handed := false
for _, p := range w.merges.last("feat/g").Pulls {
handed = handed || (p.Repository == gCtl && p.State == sdk.MergeMerging)
}
if !handed {
t.Fatalf("the status turned success and the second member was not handed: %+v", w.merges.last("feat/g"))
}
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
bridgeMerged(w, gCtl, 1, gHc, gT1)
w.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateCleared {
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
}
}},
// A condition the gate raised before it restarted, whose delivery stalls no more, is cleared at the first look.
{"a condition kept from before a restart is cleared", func(t *testing.T) {
w := newWorld(t)
told := hearConditions(w)
told.kept[conditionKeyOf("novox/app@0123456789ab")] = "urgent||"
w.settleAll()
if c := told.last("novox/app@0123456789ab"); c != nil {
t.Fatalf("cleared before the forge's open pull requests were read: %+v", c)
}
w.h.wantForge()
w.settleAll()
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != asks.StateCleared || len(told.kept) != 0 {
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
}
}},
// Review round 1, blocker 1: a pull request closed or merged on the forge with no event heard (de-spiegel #4,
// closed 2026-10-17, ready here eight days later) settles at the next complete read of the open list, and no
// condition is raised for it; a merge learnt so does not move source.main.
{"a pull request gone from the open list settles and raises nothing", func(t *testing.T) {
for _, merged := range []bool{false, true} {
w := newWorld(t)
w.op.auto = false
const repo, head = "novox/de-spiegel", "fdcbaefb3123fdcbaefb3123fdcbaefb3123fdcb"
w.h.PullUpdated(pr(repo, 4, head, "feat/x", "src/a.go"))
w.h.Checked(judgedAt(repo, 4, head, "pass", "t1t1t1t1t1t1"))
w.settleAll()
w.forge.refuse = errors.New("novox/de-spiegel#4 merges into main, which no protection rule requiring the checks guards")
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
id := IDOf(repo, head)
if len(w.h.Stalled()) == 0 {
t.Fatalf("the refused head is not stalled to begin with")
}
p := ForgePull{Number: 4, State: "closed", HeadSHA: head, Base: "main"}
if merged {
p.Merged, p.MergeCommit = true, strings.Repeat("e", 40)
}
w.forge.pulls[repo+"#4"] = p
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
told := hearConditions(w)
moves := len(w.source.moves)
w.h.wantForge()
w.settleAll()
want := Closed
if merged {
want = Merged
}
if st := w.state(id); st != want {
t.Fatalf("merged %v: the delivery is %s, wanted %s", merged, st, want)
}
if c := told.last(id); c != nil {
t.Fatalf("merged %v: a condition was said for it: %+v", merged, c)
}
if len(w.source.moves) != moves {
t.Fatalf("merged %v: source.main moved for a merge learnt by its absence: %+v", merged, w.source.moves)
}
}
}},
// Review round 2: a merge learnt after the fact moves no other delivery's trunk (an approved group of the same
// repository keeps its trunk, its state and its composed check), takes the forge's time, and a catch-up after it
// does not move source.main.
{"a merge learnt late moves no trunk and no source", func(t *testing.T) {
w := approvedGroup(t)
allGreen(w)
w.merges.manual = true
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
const lateHead, lateMerge = "9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b"
w.h.PullUpdated(pr(gCat, 9, lateHead, "fix/old", "modules/app/x.go"))
w.h.Checked(judged(gCat, 9, lateHead, "pass", gTrunk0))
w.settleAll()
late := IDOf(gCat, lateHead)
trunkOf := func() map[string]string {
out := map[string]string{}
for _, id := range []string{IDOf(gCat, gHk), IDOf(gCtl, gHc)} {
out[id] = w.delivery(id).TrunkNow + " " + string(w.delivery(id).State)
}
return out
}
before, moves := trunkOf(), len(w.source.moves)
w.forge.pulls[gCat+"#9"] = ForgePull{Number: 9, State: "closed", Merged: true, MergeCommit: lateMerge,
MergedAt: "2026-10-01T09:00:00Z", HeadSHA: lateHead, Base: "main"}
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: gHk},
{Owner: "novox", Repo: "mesh-controller", Number: 1, Base: "main", HeadSHA: gHc}}
w.h.wantForge()
w.settleAll()
d := w.delivery(late)
if d.State != Merged || !d.MergedAt.Equal(time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)) || !d.LearntLate {
t.Fatalf("the late merge is %s at %v (learnt late %v)", d.State, d.MergedAt, d.LearntLate)
}
if after := trunkOf(); fmt.Sprint(after) != fmt.Sprint(before) {
t.Fatalf("the group's trunk or state moved: %v, was %v", after, before)
}
if g := w.h.groups["feat/g"]; g.Check == nil || g.Check.OutOfDate != "" {
t.Fatalf("the group's composed check went out of date: %+v", g.Check)
}
w.h.SourceCatchUp()
w.settleAll()
if len(w.source.moves) != moves {
t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:])
}
}},
// Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10
// minutes: the gate asks itself for a look then.
{"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) {
w := approvedGroup(t)
told := hearConditions(w)
allGreen(w)
w.forge.refuse = errAskedAgain
w.op.answer(w.op.open()[0], OptionApprove)
w.settleAll()
t0 := w.h.Now()
w.h.wanted.mu.Lock()
at := w.h.wanted.at
w.h.wanted.mu.Unlock()
if at.IsZero() || at.Sub(t0) > outOfDateFor {
t.Fatalf("no look is wanted by t0+%s: %v", outOfDateFor, at)
}
for i := 0; i < 5 && w.h.Now().Before(t0.Add(outOfDateFor)); i++ { // the timer fires a look, and nothing else happens
w.h.wanted.mu.Lock()
at = w.h.wanted.at
w.h.wanted.mu.Unlock()
if at.IsZero() {
break
}
w.later(at.Sub(w.h.Now()))
w.h.React()
}
c := told.last(IDOf(gCat, gHk))
if c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent {
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
}
}},
// Review round 1, blocker 3: a gate that restarts while a head still waits on the forge does not clear its
// condition (the head's first sight is in memory alone, so its bound runs again), and does not say it again.
{"a restart keeps the condition of a head still waiting", func(t *testing.T) {
w := newWorld(t)
told := hearConditions(w)
const head = "bfe82315f42c8f4509d0598c32569a202d5eada6"
id := IDOf("novox/hq", head)
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}}}
w.h.wantForge()
w.settleAll()
w.later(UncheckedAfter + time.Minute)
w.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateRaised {
t.Fatalf("the unchecked head is not raised: %+v", c)
}
before := len(told.told)
again := newWorldOver(t, w.store, w.forge, w.h.Now()) // the gate restarts
again.h.Say, again.h.Conditions = told.say, told
again.h.wantForge()
again.settleAll()
again.later(time.Minute)
again.settleAll()
if len(told.told) != before {
t.Fatalf("the restart said something of a head still waiting: %+v", told.told[before:])
}
w.forge.open = nil // it closes
again.h.wantForge()
again.settleAll()
if c := told.last(id); c == nil || c.State != asks.StateCleared {
t.Fatalf("closed, and its condition is not cleared: %+v", c)
}
}},
// The kind behind the day's fault: a decision on a group's state named a word GroupState no longer says. Every word
// it says is one groupStatusState knows, and the merge goes on exactly in ready and merging.
{"every word of a group's state is decided on", func(t *testing.T) {
for _, word := range groupWords {
if groupStatusState(word) == "pending" && word != GroupChecking {
t.Errorf("groupStatusState does not know %q", word)
}
if may := groupMayMerge(word); may != (word == GroupReady || word == GroupMerging) {
t.Errorf("groupMayMerge(%q) = %v", word, may)
}
}
}},
}
for _, c := range cases {
t.Run(c.name, c.run)
}
}
// allGreenFor makes both merge checks a success at every member's head.
func allGreenFor(w *world, ms ...member) {
for _, m := range ms {
setChecks(w, m.repo, m.head, "success", "success")
}
}
// bridgeMerged is the forge bridge's word that it merged a member.
func bridgeMerged(w *world, repository string, number int, head, merge string) {
w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root",
Trees: map[string]string{"app": "tree"}})
}
@@ -6,7 +6,7 @@ import "git.novox.be/novox/mesh-sdk/go/announce"
// issue 1790): the module says it itself, from its code, before it serves, and a reader is granted what it said
// (clause 3). A state is readable when another module's manifest reads it (keeps_test.go names the readers). keeps_test.go holds the list to the
// manifest's `state`, and keeps_integration_test.go that the module announces it on a bus.
var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}}
var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}}
// announceKept says Kept through keeps, which is stdio.Keeps.
func announceKept(keeps func(...announce.State) error) error { return keeps(Kept...) }
@@ -67,6 +67,9 @@ func main() {
Source: kvSource{},
Owed: kvOwed{},
Pulls: kvPulls{},
// The gate says what stalled to the operator itself (issue 1275, ADR 0437 §9).
Say: sayOnSeat,
Conditions: kvConditions{},
}
h.init()
l := &listening{now: "not yet: starting"}
@@ -91,9 +91,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) {
t.Fatalf("emits %v", m.Emits)
}
// Seven buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3.
// Eight buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3, and the conditions it raised (issue 1275).
buckets := m.buckets()
if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}) {
if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source", "conditions"}) {
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
@@ -102,10 +102,10 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
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
// uses no operator-channel: the teller asks (ADR 0437 §6).
// uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks (ADR 0437 §6).
want := []string{"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls"}
if len(m.Uses) != 0 {
t.Fatalf("uses %v: the teller asks, not the gate", m.Uses)
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
t.Fatalf("uses %v: operator-channel, for its conditions alone", m.Uses)
}
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
@@ -356,6 +356,9 @@ func (h *Holder) settleMerges() []func() {
var members []string
for hid := range r.Heads {
m := h.deliveries[hid]
if m.LearntLate {
continue // learnt after the fact: never written to source.main (settleGone)
}
commit := m.MergedAs
if m.Ready != nil && m.Ready.MergeCommit != "" {
commit = m.Ready.MergeCommit
@@ -363,6 +366,9 @@ func (h *Holder) settleMerges() []func() {
heads[m.Repository] = commit
members = append(members, hid)
}
if len(members) == 0 {
continue // every member learnt after the fact: nothing to write
}
sort.Strings(members)
last := h.deliveries[members[len(members)-1]]
out = append(out, func() { h.finish(name, key, approved, last.ID, heads, members) })
@@ -441,7 +447,7 @@ func (h *Holder) settleForgeMerges() []func() {
again = h.newestInSource()
}
for _, d := range h.deliveries {
if d.MergedAs == "" || (d.InSource && !again[d]) || gateMerges(d) {
if d.MergedAs == "" || (d.InSource && !again[d]) || gateMerges(d) || d.LearntLate {
continue
}
if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) {
@@ -558,7 +564,7 @@ func (h *Holder) SourceCatchUp() {
func (h *Holder) newestInSource() map[*Delivery]bool {
newest := map[string]*Delivery{}
for _, d := range h.deliveries {
if d.MergedAs == "" || !d.InSource || gateMerges(d) || d.Base != MergeBranch || d.replayed() {
if d.MergedAs == "" || !d.InSource || gateMerges(d) || d.LearntLate || d.Base != MergeBranch || d.replayed() {
continue
}
if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) {
@@ -82,8 +82,11 @@ type Delivery struct {
MergedAt time.Time `json:"merged_at"`
// InSource says the merge was looked at for source.main: written there, or left as older than the trunk's head (issue 888).
// A merge the gate handed to the bridge on an Approve is moved there by finish instead (merges.go).
InSource bool `json:"in_source,omitempty"`
HeldWhy string `json:"held_why,omitempty"`
InSource bool `json:"in_source,omitempty"`
// LearntLate says its merge was learnt after the fact, from its absence among the forge's open pull requests
// (settleGone, issue 1275): it moved no trunk here, and it never moves source.main, not even at a catch-up.
LearntLate bool `json:"learnt_late,omitempty"`
HeldWhy string `json:"held_why,omitempty"`
// FromForge is a delivery made from the forge's word at its merge: its head was never heard by this owner.
FromForge bool `json:"from_forge,omitempty"`
@@ -45,7 +45,9 @@ type ForgePull struct {
Head string `json:"head,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
MergeCommit string `json:"merge_commit_sha,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
// MergedAt is when it merged, as the forge says it (RFC 3339); empty when not merged or not said.
MergedAt string `json:"merged_at,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
}
// ForgeStatus is one status of a commit, the newest of its context.
@@ -57,7 +57,7 @@ func (h *Holder) Work() {
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then
// the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
// the conditions of what stalled (conditions.go), and then the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
func (h *Holder) React() {
h.reactMu.Lock()
defer h.reactMu.Unlock()
@@ -75,6 +75,7 @@ func (h *Holder) React() {
}
h.look()
h.Flush()
h.sayConditions()
h.arm()
}
@@ -29,7 +29,7 @@ import (
// - a group member only once the group's composed check passed for its members' heads as they are;
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
// controller raises as the condition `delivery.<id>.stalled`. So is a merge the tool refused for an hour.
// gate says to the operator as a condition (conditions.go). So is a merge the tool refused for an hour.
//
// The review record is read and put in the ask; it is never a gate (ADR 0421 §5). What this owner does is kept on the
// delivery (Ready), said in its log, and the merge's transitions name the Approve as their warrant.
@@ -401,13 +401,14 @@ func (d *Delivery) holdsForReview() bool {
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
// A group part way through that pass is merging, and its next member's turn is then (issue 1275).
func (h *Holder) groupTurnToMerge(d *Delivery) string {
g := h.groups[d.Group]
if g == nil {
return ""
}
members := h.membersOf(g)
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
if state, why := GroupState(g, members); !groupMayMerge(state) {
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
}
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
@@ -417,6 +418,16 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string {
return ""
}
// groupMayMerge is whether a group in this state hands its next member to the forge bridge: one ready, and one merging (a
// member merged, the pass goes on). Every other word holds the pass.
func groupMayMerge(state string) bool {
switch state {
case GroupReady, GroupMerging:
return true
}
return false
}
// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key
// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do.
func (h *Holder) sayOn(d *Delivery, body string) func() {
@@ -427,7 +438,7 @@ func (h *Holder) sayOn(d *Delivery, body string) func() {
}
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
// as stalled lines, which the controller raises as conditions. Under the lock.
// as stalled lines, which the gate says as conditions (conditions.go). Under the lock.
func (h *Holder) readyLines(now time.Time) []StalledLine {
var out []StalledLine
for _, d := range h.deliveries {
@@ -3,6 +3,8 @@ package main
import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
"time"
)
@@ -16,7 +18,7 @@ import (
// (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, and one whose
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
// gate says as a condition (conditions.go) like any other delivery past its bound. The delivery domain's own
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
//
// A head whose check started and never answered is said the same way (novox/hq issue 438). Counting any merge-check
@@ -175,8 +177,24 @@ func (h *Holder) SeeOpenPulls() error {
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
return err
}
for _, gone := range h.seeOpen(open) {
h.settleGone(gone)
}
return nil
}
// openPullsMost is the most open pull requests the forge's gitea_open_pulls answers: it reads 20 pages of 50 and says
// nothing of the rest (mesh-catalog modules/gitea client.ts searchOpenPulls). A list that long may be cut, so what is
// missing from it is not taken as closed.
const openPullsMost = 20 * 50
// seeOpen keeps what the forge's open pull requests say, and answers the deliveries whose pull request is not among them:
// open here, closed on the forge with no event heard (a close or merge while the gate was away, or an event lost). Only
// from a complete list. Takes the lock.
func (h *Holder) seeOpen(open []OpenPull) []*Delivery {
h.mu.Lock()
defer h.mu.Unlock()
h.forgeRead = true
now := h.Now()
seen := map[string]unchecked{}
for _, p := range open {
@@ -215,7 +233,55 @@ func (h *Holder) SeeOpenPulls() error {
seen[id] = u
}
h.unchecked = seen
return nil
if len(open) >= openPullsMost {
return nil
}
listed := map[string]bool{}
for _, p := range open {
listed[strings.ToLower(p.Owner+"/"+p.Repo)+"#"+strconv.Itoa(p.Number)] = true
}
var gone []*Delivery
for _, d := range h.deliveries {
if d.State.Final() || d.MergedAs != "" || d.Number <= 0 || listed[strings.ToLower(d.Repository)+"#"+strconv.Itoa(d.Number)] {
continue
}
gone = append(gone, d)
}
sort.Slice(gone, func(i, j int) bool { return gone[i].ID < gone[j].ID })
return gone
}
// settleGone reads from the forge a pull request the open list does not hold, and takes its merge or its close as the
// forge's event would have said it (issue 1275, review round 1: a pull request closed on 2026-10-17 was still ready here
// eight days later). A merge learnt this way is past: its delivery is merged with the forge's time, moves no other
// delivery's trunk, and never moves source.main (pullMerged, late); the trunk's merges heard as they happened move
// those. A forge that cannot say changes nothing. Outside the lock.
func (h *Holder) settleGone(d *Delivery) {
h.mu.Lock()
id, owner, repo, number, head, base, branch := d.ID, d.Owner(), d.Repo(), d.Number, d.Commit, d.Base, d.Branch
h.mu.Unlock()
// Read by its number alone: the forge reads the pull request's own head, which it keeps after the branch is gone.
fc, err := h.Forge.Statuses(owner, repo, number, "", base)
if err != nil || fc == nil || fc.Pull == nil {
if err != nil {
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests, and the forge cannot say what became of it: %v", id, err)
}
return
}
p := fc.Pull
if p.State != "closed" || p.Number != number {
return // open again, a state the forge did not say, or not this pull request: the next reading says
}
ev := PullEvent{Owner: owner, Repo: repo, Number: number, Base: base, Head: branch, HeadSHA: head, HTMLURL: p.HTMLURL}
if p.Merged && p.MergeCommit != "" {
ev.MergeCommit, ev.Merged, ev.MergedAt = p.MergeCommit, true, p.MergedAt
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it merged as %s at %s", id,
shortOf(p.MergeCommit, 12), orUnknown(p.MergedAt))
h.pullMerged(ev, true)
return
}
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it was closed unmerged", id)
h.PullClosed(ev)
}
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
@@ -129,7 +129,7 @@ func (h *Holder) Groups(all bool) []GroupLine {
continue
}
state, why, _ := h.groupState(g, members)
if !all && (state == "merged" || state == "closed") {
if !all && (state == GroupMerged || state == GroupClosed) {
continue
}
l := GroupLine{ID: g.ID, State: state, Why: why, ComposedGate: true}
@@ -185,7 +185,7 @@ type StalledLine struct {
// NeedsNewHead is what a stalled line needs when its cure is its author's new head (ADR 0505 §5).
const NeedsNewHead = "new-head"
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
// Stalled is the `stalled` verb: what the gate says to the operator as conditions (conditions.go).
func (h *Holder) Stalled() []StalledLine {
h.mu.Lock()
defer h.mu.Unlock()
+5 -1
View File
@@ -33,6 +33,9 @@
"group",
"merged"
],
"uses": [
"operator-channel"
],
"invokes": [
"gitea.gitea_commit_statuses",
"gitea.gitea_contains",
@@ -57,7 +60,8 @@
{
"name": "source",
"history": 64
}
},
"conditions"
],
"reads": [
"mesh-delivery-teller.answers",