The gate says a group again when a member joins, leaves or pushes, or its why changes, so the forge never shows a stale member count (issue 774)

On 2026-10-30 mesh-catalog #571 joined fix/held-module-and-assigner-contract at 21:59; the group stayed
checking, so mesh/delivery-group kept saying "3 of 3 member(s) ready" for a group of four, and #571 had
no mesh/delivery-group status until the state changed.

Quick fix: compare the why too. Thorough fix: compare the why and the member ids (each names its head),
so a push that keeps the count is said as well. Chose thorough: same size, covers the push case.
This commit is contained in:
2026-10-30 22:29:42 +01:00
parent 5d9cead336
commit fdd1833143
3 changed files with 56 additions and 4 deletions
@@ -0,0 +1,35 @@
package main
import (
"strings"
"testing"
)
// A member that joins a group whose state stays checking is said on mesh/delivery-group, and the members already there
// read the new count: on 2026-10-30 mesh-catalog #571 joined a group of three at 21:59, and the forge kept saying
// "3 of 3 member(s) ready" of the group of four, since the line was written again only when the state changed.
func TestAGroupSaysAJoiningMember(t *testing.T) {
w := newWorld(t)
const ctlHead, catHead, hostHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2", "e3e3e3e3e3e3e3e3"
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(verdict("novox/mesh-controller", 1, ctlHead, "pass"))
w.settleAll()
first := w.forge.statuses["novox/mesh-controller@"+ctlHead+" mesh/delivery-group"]
if !strings.Contains(first, "1 of 2") {
t.Fatalf("before the join the group says %q, want 1 of 2", first)
}
w.h.PullUpdated(pr("novox/mesh-host", 3, hostHead, "feat/x", "cmd/a.go"))
w.settleAll()
g := w.h.groups["feat/x"]
st, why := GroupState(g, w.h.membersOf(g))
for _, c := range []struct{ name, key, want string }{
{"a member already there", "novox/mesh-controller@" + ctlHead, "1 of 3"},
{"the member that joined", "novox/mesh-host@" + hostHead, "1 of 3"},
} {
s := w.forge.statuses[c.key+" mesh/delivery-group"]
if !strings.Contains(s, c.want) {
t.Errorf("%s: mesh/delivery-group says %q while the group is %s (%s); want %q", c.name, s, st, why, c.want)
}
}
}
@@ -1071,8 +1071,10 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
if held {
reviewSaid = why
}
if state != g.Said || status != g.SaidStatus || reviewSaid != g.SaidReview {
g.Said, g.SaidStatus, g.SaidReview = state, status, reviewSaid
saidMembers := strings.Join(sortedIDs(members), " ")
if state != g.Said || status != g.SaidStatus || reviewSaid != g.SaidReview || why != g.SaidWhy ||
saidMembers != g.SaidMembers {
g.Said, g.SaidStatus, g.SaidReview, g.SaidWhy, g.SaidMembers = state, status, reviewSaid, why, saidMembers
for _, d := range members {
g.Owed = append(g.Owed, Effect{Kind: EffectStatus, Commit: d.Commit, Context: "mesh/delivery-group",
State: status, Line: clip(fmt.Sprintf("group %s %s: %s", g.ID, state, why), 140),
@@ -366,8 +366,13 @@ type Group struct {
SaidStatus string `json:"said_status,omitempty"`
// SaidReview is the wait for a review last said of the group while it was held for one (ADR 0496 §2): a change of
// the members still waiting is said too, though the state did not change.
SaidReview string `json:"said_review,omitempty"`
Owed []Effect `json:"owed,omitempty"`
SaidReview string `json:"said_review,omitempty"`
// SaidWhy and SaidMembers are the why and the member ids (each names its head) last said: a member joining, leaving or
// pushing, or a count in the why moving, is said too, though the state did not change (2026-10-30: a group of four
// said "3 of 3 member(s) ready" for ten minutes, and the member that joined had no mesh/delivery-group at all).
SaidWhy string `json:"said_why,omitempty"`
SaidMembers string `json:"said_members,omitempty"`
Owed []Effect `json:"owed,omitempty"`
}
// GroupHold is a group held back from its Approve or its merge because a member's required status is not a success.
@@ -404,6 +409,16 @@ func commitsOf(ds []*Delivery) map[string]string {
return out
}
// sortedIDs are the members' ids (repository@head), sorted.
func sortedIDs(ds []*Delivery) []string {
out := make([]string, 0, len(ds))
for _, d := range ds {
out = append(out, d.ID)
}
sort.Strings(out)
return out
}
// sameMembers is whether two member→commit maps are one.
func sameMembers(a, b map[string]string) bool {
if len(a) != len(b) {