diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/groupsaid_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/groupsaid_test.go new file mode 100644 index 00000000..ce07127c --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/groupsaid_test.go @@ -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) + } + } +} 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 8ba98d5c..74de7916 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -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), 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 05e86510..434a3822 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -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) {