From 6ac5c934d2744fb87cb8ca52129dc566054ac641 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 14:41:20 +0100 Subject: [PATCH] The gate holds no merge for a check: the hold, the turns, their stalled line and tools, and check-gates-merge go; the merge judges the operator's Approve alone (milestone 47/G, W11.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0661: the check gates the ask, in one place (askWaits); once the Approve covers the heads (approvedWaits) no verdict, carry, composed check or required status is judged, an Approve given before a trunk move merges onto the new trunk, and a failure after the Approve is said once on the pull request and as a stalled line naming the merge it follows. A check failing before the answer withdraws the ask. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ (Joined from 43696644.) Builds: design 47 G §278, test 52 (GATE10) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-gate/adr505_test.go | 25 +- .../cmd/mesh-delivery-gate/adr548_test.go | 244 --------- .../cmd/mesh-delivery-gate/adr661_test.go | 136 ++++++ .../cmd/mesh-delivery-gate/approval.go | 6 +- .../cmd/mesh-delivery-gate/conditions.go | 3 - .../cmd/mesh-delivery-gate/events.go | 4 +- .../cmd/mesh-delivery-gate/events_test.go | 2 +- .../cmd/mesh-delivery-gate/group_merge.go | 20 +- .../cmd/mesh-delivery-gate/helpers_test.go | 6 - .../cmd/mesh-delivery-gate/holder.go | 9 +- .../cmd/mesh-delivery-gate/issue1275_test.go | 62 +-- .../issue1275_whole_test.go | 95 ++-- .../cmd/mesh-delivery-gate/issue1348_test.go | 13 +- .../cmd/mesh-delivery-gate/keeps.go | 2 +- .../cmd/mesh-delivery-gate/main.go | 19 - .../cmd/mesh-delivery-gate/manifest_test.go | 14 +- .../cmd/mesh-delivery-gate/model.go | 7 + .../cmd/mesh-delivery-gate/onestatus.go | 10 +- .../cmd/mesh-delivery-gate/ready.go | 121 ++--- .../cmd/mesh-delivery-gate/ready_test.go | 106 ---- .../cmd/mesh-delivery-gate/settings.go | 25 +- .../cmd/mesh-delivery-gate/table.go | 23 +- .../cmd/mesh-delivery-gate/table_test.go | 35 +- .../cmd/mesh-delivery-gate/turns.go | 461 ------------------ .../cmd/mesh-delivery-gate/values.go | 61 --- .../cmd/mesh-delivery-gate/values_test.go | 13 - .../cmd/mesh-delivery-gate/verbs.go | 7 +- .../mesh-delivery-gate/files/settings.json | 1 - modules/mesh-delivery-gate/module.json | 15 +- 29 files changed, 362 insertions(+), 1183 deletions(-) delete mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go create mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr661_test.go delete mode 100644 modules/mesh-delivery-gate/cmd/mesh-delivery-gate/turns.go diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go index 520dbb8bd..3848448a7 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr505_test.go @@ -36,15 +36,6 @@ func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) { if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour { t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound()) } - if !h.checkGates() { - t.Fatalf("check-gates-merge unset reads false; the default is true") - } - if s, err = read(`{"check-gates-merge": false}`); err != nil || s.CheckGatesMerge == nil || *s.CheckGatesMerge || h.checkGates() { - t.Fatalf("check-gates-merge false: %+v %v, gates %v", s, err, h.checkGates()) - } - if _, err := read(`{"check-gates-merge": "no"}`); err == nil { - t.Fatalf("a check-gates-merge that is not true or false was taken") - } if _, err := read(`{"author-new-head-minutes": 0}`); err == nil { t.Fatalf("a bound of zero minutes was taken") } @@ -139,9 +130,9 @@ func TestAnyOtherRefusalIsStalledAtOnce(t *testing.T) { } } -// rejectedAfterItsApprove is a delivery approved and then rejected: its check failed after the ask (as in events_test.go), +// rejectedBeforeItsAnswer is a delivery approved and then rejected: its check failed after the ask (as in events_test.go), // its pull request's description naming who pushed it when pushedBy is given. -func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) { +func rejectedBeforeItsAnswer(t *testing.T, pushedBy string) (*world, string) { t.Helper() w := newWorld(t) w.op.auto = false @@ -163,7 +154,11 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) { d.Check = nil w.h.mu.Unlock() hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1")) - approve(w) + w.settleAll() + // A check that fails before the operator answers withdraws the ask (novox/hq ADR 0661 §5, ADR 0441 §1). + if open := w.op.open(); len(open) != 0 { + t.Fatalf("the ask stands after its check failed: %v", open) + } w.h.React() if w.state(id) != Rejected { t.Fatalf("not rejected: %s", w.state(id)) @@ -175,7 +170,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) { // with no new head, in words naming its author (its branch, and the session that pushed it) and needing a new head — // never "stop it", never "the operator's". func TestARejectedDeliveryIsItsAuthorsForTwoHours(t *testing.T) { - w, id := rejectedAfterItsApprove(t, "quiet-deliveries@shanks") + w, id := rejectedBeforeItsAnswer(t, "quiet-deliveries@shanks") said := false for _, c := range w.forge.comments { said = said || strings.Contains(c, "not merged") @@ -208,7 +203,7 @@ func TestARejectedDeliveryIsItsAuthorsForTwoHours(t *testing.T) { // ADR 0505 §5: with no Pushed-by line, the branch alone names its author. func TestARejectedDeliveryWithNoPushedByLineIsNamedByItsBranch(t *testing.T) { - w, id := rejectedAfterItsApprove(t, "") + w, id := rejectedBeforeItsAnswer(t, "") w.later(2*time.Hour + time.Minute) if got := linesFor(w, id); len(got) != 1 || got[0].Author != "the author of feat/x" { t.Fatalf("not named by its branch: %+v", got) @@ -217,7 +212,7 @@ func TestARejectedDeliveryWithNoPushedByLineIsNamedByItsBranch(t *testing.T) { // ADR 0505 §5: a new head within the two hours ends it: the rejected head is never the operator's. func TestANewHeadWithinTwoHoursKeepsTheRejectedHeadFromTheOperator(t *testing.T) { - w, id := rejectedAfterItsApprove(t, "quiet-deliveries@shanks") + w, id := rejectedBeforeItsAnswer(t, "quiet-deliveries@shanks") w.later(time.Hour) w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go")) w.settleAll() diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go deleted file mode 100644 index fdcaacc0a..000000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr548_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package main - -import ( - "fmt" - "strings" - "sync" - "testing" - "time" -) - -// One check per delivery (novox/hq ADR 0548), the gate's part: nothing merges into a repository while a check of other heads -// judges it (§2); an approved merge holds a turn on its repositories (§3); a held merge past its bound is a stalled line, read -// and handed by two tools (§4); an approved delivery whose trunk moved merges on its carried verdict with no second ask (§8). - -const otherHead = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - -// approvedLone is a lone delivery into oneRepo whose check passed, approved by the operator (the world approves at once). -func approvedLone(w *world) string { - w.t.Helper() - p := pr(oneRepo, 1, oneHead, "feat/one") - requiresCheck(w, p) - w.h.PullUpdated(p) - putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) - w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) - return IDOf(oneRepo, oneHead) -} - -// memTurns is the bucket turns in memory; it logs each write with the merges the fake bridge had made by then. -type memTurns struct { - mu sync.Mutex - m map[string]Turn - log []string - merges func() int -} - -func newMemTurns(w *world) *memTurns { - return &memTurns{m: map[string]Turn{}, merges: func() int { return len(w.forge.merges) }} -} - -func (f *memTurns) All() (map[string]Turn, error) { - f.mu.Lock() - defer f.mu.Unlock() - out := map[string]Turn{} - for k, v := range f.m { - out[k] = v - } - return out, nil -} - -func (f *memTurns) Put(t Turn) error { - if _, err := EncodeTurn(t); err != nil { - return err - } - f.mu.Lock() - defer f.mu.Unlock() - f.m[t.Repository] = t - f.log = append(f.log, fmt.Sprintf("put %s after %d merges", t.Repository, f.merges())) - return nil -} - -func (f *memTurns) Delete(repository string) error { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.m, repository) - f.log = append(f.log, fmt.Sprintf("delete %s after %d merges", repository, f.merges())) - return nil -} - -func (f *memTurns) said() string { f.mu.Lock(); defer f.mu.Unlock(); return strings.Join(f.log, "; ") } - -func TestOneCheckPerDeliveryAtTheGate(t *testing.T) { - for name, run := range map[string]func(t *testing.T){ - // §2: a merge into a repository a running verdict names is not written to merges, and is written on that verdict's pass. - "an approved merge waits for a running check on its repository and merges on its end": func(t *testing.T) { - w := newWorld(t) - putCheckerVerdict(w, "running", headOf(oneRepo, 9, otherHead)) - id := approvedLone(w) - w.settleAll() - if w.op.count() != 1 || len(w.forge.merges) != 0 { - t.Fatalf("asked %d times, merges %v: want the Approve asked and the merge held", w.op.count(), w.forge.merges) - } - d := w.delivery(id) - if d.Ready == nil || !strings.Contains(d.Ready.Waits, "waits for the check of g") || !strings.Contains(d.Ready.Waits, oneRepo) || - d.Ready.HeldSince == nil { - t.Fatalf("the merge does not say what it waits for: %+v", d.Ready) - } - // §9: deliveries says what it waits for. - for _, l := range w.h.Deliveries("", "", "", false) { - if l.ID == id && !strings.Contains(l.Waits, "approved; waits for the check of g") { - t.Errorf("deliveries says it waits for %q", l.Waits) - } - } - // §4: past 30 minutes the held merge is a stalled line naming the check it waits for and since when. - for _, l := range w.h.Stalled() { - if l.ID == id { - t.Fatalf("a stalled line within the bound: %+v", l) - } - } - w.later(turnBound + time.Minute) - w.settleAll() - var line *StalledLine - for _, l := range w.h.Stalled() { - if l.ID == id { - line = &l - } - } - if line == nil || line.Bound != "30m0s" || !strings.Contains(line.Says, "waits for the check of g") || !strings.Contains(line.Says, "held since") || - !strings.Contains(line.Says, "checker_status") || !strings.Contains(line.H2, "delivery_hand_now") { - t.Fatalf("the stalled line of the held merge = %+v", line) - } - putCheckerVerdict(w, "pass", headOf(oneRepo, 9, otherHead)) - w.settleAll() - if len(w.forge.merges) != 1 || w.op.count() != 1 { - t.Fatalf("after the check's end: merges %v, asked %d times; want merged with no second ask", w.forge.merges, w.op.count()) - } - }, - "a running check on another repository holds nothing": func(t *testing.T) { - w := newWorld(t) - putCheckerVerdict(w, "running", headOf(oneRepo2, 9, otherHead)) - approvedLone(w) - w.settleAll() - if len(w.forge.merges) != 1 { - t.Fatalf("merges %v: a check of another repository held the merge (%s)", w.forge.merges, w.logs()) - } - }, - "the delivery's own running verdict does not hold it": func(t *testing.T) { - w := newWorld(t) - w.op.auto = false - id := approvedLone(w) - w.settleAll() - putCheckerVerdict(w, "running", headOf(oneRepo, 1, oneHead)) // its own heads judged again (a carry) - for _, k := range w.op.open() { - w.op.answer(k, OptionApprove) - } - w.settleAll() - if d := w.delivery(id); len(w.forge.merges) != 1 || d.Ready == nil || d.Ready.HeldSince != nil { - t.Fatalf("merges %v, run %+v: its own check held it", w.forge.merges, d.Ready) - } - }, - // §3: the turn is written before the verdicts are read for the merge, and deleted after the last merge. - "a lone merge's turn is written before it is handed and deleted once it merged": func(t *testing.T) { - w := newWorld(t) - turns := newMemTurns(w) - w.h.Turns = turns - approvedLone(w) - w.settleAll() - if len(w.forge.merges) != 1 || turns.said() != "put "+oneRepo+" after 0 merges; delete "+oneRepo+" after 1 merges" { - t.Fatalf("merges %v; the turns were %q", w.forge.merges, turns.said()) - } - }, - "a group's turns stand on every member's repository until its last member merged": func(t *testing.T) { - w := newWorld(t) - turns := newMemTurns(w) - w.h.Turns = turns - a, b := pr(oneRepo, 1, oneHead, "feat/two"), pr(oneRepo2, 2, oneHead2, "feat/two") - requiresCheck(w, a, b) - w.h.PullUpdated(a) - w.h.PullUpdated(b) - w.h.wantForge() - putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead), headOf(oneRepo2, 2, oneHead2)) - w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) - w.h.CommitStatus(checkStatus(oneRepo2, oneHead2, "success", oneTrunk)) - for range 3 { - w.settleAll() - } - want := "put " + oneRepo2 + " after 0 merges; put " + oneRepo + " after 0 merges; delete " + oneRepo2 + " after 2 merges; delete " + oneRepo + " after 2 merges" - if len(w.forge.merges) != 2 || turns.said() != want { - t.Fatalf("merges %v; the turns were %q, want %q", w.forge.merges, turns.said(), want) - } - }, - // §8: an approved delivery whose trunk moved goes back to proposed, waits for its carried verdict rather than being - // handed on the old one, and merges on it with no second ask. - "an approved merge whose trunk moved merges on its carried verdict with no second ask": func(t *testing.T) { - w := newWorld(t) - putCheckerVerdict(w, "running", headOf(oneRepo, 9, otherHead)) // holds the approved merge (§2) - id := approvedLone(w) - w.settleAll() - if w.op.count() != 1 || len(w.forge.merges) != 0 { - t.Fatalf("asked %d times, merges %v; want approved and held", w.op.count(), w.forge.merges) - } - w.h.PullMerged(merged(oneRepo, 5, otherHead, oneTrunk2)) // a merge at the forge moves the trunk - putCheckerVerdict(w, "pass", headOf(oneRepo, 9, otherHead)) - w.settleAll() - if len(w.forge.merges) != 0 { - t.Fatalf("merges %v: handed on a verdict judged on the old trunk", w.forge.merges) - } - d := w.delivery(id) - if d.Ready == nil || !strings.Contains(d.Ready.Waits, "being carried") { - t.Fatalf("after the trunk moved it waits for %q, want its verdict carried", d.Ready.Waits) - } - // The checker's carried line: the head on the new trunk, carried from the old one. - carried := checkStatus(oneRepo, oneHead, "success", oneTrunk2) - carried.Description = "pass " + oneHead[:12] + " on " + oneTrunk2[:12] + " | carried from " + oneTrunk[:12] + " | unit 0 run, 4 kept" - if head, trunk := checkLine(carried.Description); head != oneHead[:12] || trunk != oneTrunk2[:12] { - t.Fatalf("the carried line reads as %s on %s", head, trunk) - } - if head, trunk := checkLine("failed: a test"); head != "" || trunk != "" { - t.Fatalf("a line that names no commits gave %q %q", head, trunk) - } - putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) - w.h.CommitStatus(carried) - w.settleAll() - if w.op.count() != 1 { - t.Errorf("the Approve was asked %d times, want once: it stands through the carry", w.op.count()) - } - if d := w.delivery(id); d.Check == nil || d.Check.Trunk != oneTrunk2[:12] || len(w.forge.merges) != 1 { - t.Errorf("after the carry: verdict %+v, merges %v; want merged on the carried verdict", d.Check, w.forge.merges) - } - }, - // §4: delivery_turns reads every turn with the check it waits for; delivery_hand_now hands a held merge at once on the - // operator's word, and refuses one no check holds. - "the turns are read, and a held merge is handed now on the operator's word": func(t *testing.T) { - w := newWorld(t) - w.h.Turns = newMemTurns(w) - putCheckerVerdict(w, "running", headOf(oneRepo, 9, otherHead)) - id := approvedLone(w) - w.settleAll() - read := w.h.TurnsRead() - lines := read["turns"].([]TurnLine) - if len(lines) != 1 || lines[0].Repository != oneRepo || len(lines[0].Turn.Waiting) != 1 || lines[0].Turn.Waiting[0].WaitsFor == "" || - !strings.Contains(lines[0].Waits["feat/one"].(string), "the check g.") || read["written"] != true { - t.Fatalf("delivery_turns = %+v", read) - } - if _, err := w.h.HandNow(id, ""); err == nil || !strings.Contains(err.Error(), "why") { - t.Errorf("a hand without the operator's why: %v", err) - } - if _, err := w.h.HandNow(IDOf(oneRepo2, oneHead2), "x"); err == nil { - t.Error("an unknown delivery was handed") - } - if _, err := w.h.HandNow(id, "the operator, on telegram: merge it now"); err != nil { - t.Fatal(err) - } - w.settleAll() - if len(w.forge.merges) != 1 || w.delivery(id).Ready.HandNow == "" { - t.Fatalf("merges %v after the hand", w.forge.merges) - } - if _, err := w.h.HandNow(id, "again"); err == nil { - t.Error("a merged delivery was handed again") - } - }, - } { - t.Run(name, run) - } -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr661_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr661_test.go new file mode 100644 index 000000000..164d3a1f7 --- /dev/null +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/adr661_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// No check gates a merge (novox/hq ADR 0661), the gate's part: no approved merge waits for another delivery's running check, +// and no turn or stalled line holds it (§1); the merge judges the operator's Approve alone, so an Approve given before a trunk +// move merges onto the new trunk with no wait for its carry (§3); a failure after the Approve is said once on the pull +// request and as a stalled line naming the merge it follows, and refuses nothing (§5). + +const otherHead = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + +// approvedLone is a lone delivery into oneRepo whose check passed, approved by the operator (the world approves at once). +func approvedLone(w *world) string { + w.t.Helper() + p := pr(oneRepo, 1, oneHead, "feat/one") + requiresCheck(w, p) + w.h.PullUpdated(p) + putCheckerVerdict(w, "pass", headOf(oneRepo, 1, oneHead)) + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk)) + return IDOf(oneRepo, oneHead) +} + +// sayingFailed is the forge's word that oneHead's check failed on the trunk commit given (a carry's line). +func sayingFailed(trunk string) ForgeStatusEvent { + e := checkStatus(oneRepo, oneHead, "failure", trunk) + e.Description = "failed " + oneHead[:12] + " on " + trunk[:12] + " | a test failed" + return e +} + +func lineOf(w *world, id string) *StalledLine { + for _, l := range w.h.Stalled() { + if l.ID == id { + return &l + } + } + return nil +} + +func TestNoCheckGatesAMergeAtTheGate(t *testing.T) { + for name, run := range map[string]func(t *testing.T){ + // §1: a running check of other heads on the same repository holds nothing, and no stalled line is raised for it. + "an approved merge into a repository under a running check merges at once": func(t *testing.T) { + w := newWorld(t) + putCheckerVerdict(w, "running", headOf(oneRepo, 9, otherHead)) + id := approvedLone(w) + w.settleAll() + if w.op.count() != 1 || len(w.forge.merges) != 1 { + t.Fatalf("asked %d times, merges %v: want asked once and merged at once (%s)", w.op.count(), w.forge.merges, w.logs()) + } + w.h.PullMerged(merged(oneRepo, 1, oneHead, strings.Repeat("c", 40))) // the seat git says the merge too + w.later(time.Hour) + w.settleAll() + if l := lineOf(w, id); l != nil { + t.Fatalf("a stalled line for a merge a running check once held: %+v", l) + } + }, + "the delivery's own running verdict does not hold it": func(t *testing.T) { + w := newWorld(t) + w.op.auto = false + id := approvedLone(w) + w.settleAll() + putCheckerVerdict(w, "running", headOf(oneRepo, 1, oneHead)) // its own heads judged again (a carry) + for _, k := range w.op.open() { + w.op.answer(k, OptionApprove) + } + w.settleAll() + if d := w.delivery(id); len(w.forge.merges) != 1 || d.Ready == nil { + t.Fatalf("merges %v, run %+v: its own check held it", w.forge.merges, d.Ready) + } + }, + // §3 (and ADR 0548 §8): the ask stands through a trunk move; the Approve given then merges onto the new trunk on the + // verdict judged against the old one, with no wait for its carry and no second ask. + "an Approve given after a trunk move merges at once, its carry after": func(t *testing.T) { + w := newWorld(t) + w.op.auto = false + id := approvedLone(w) + w.settleAll() + if w.op.count() != 1 || len(w.forge.merges) != 0 { + t.Fatalf("asked %d times, merges %v: want asked and not merged yet", w.op.count(), w.forge.merges) + } + w.h.PullMerged(merged(oneRepo, 5, otherHead, oneTrunk2)) // a merge at the forge moves the trunk + w.settleAll() + for _, k := range w.op.open() { + w.op.answer(k, OptionApprove) + } + w.settleAll() + d := w.delivery(id) + if len(w.forge.merges) != 1 || w.op.count() != 1 || d.State != Ready && d.State != Merged { + t.Fatalf("merges %v, asked %d times, %s (waits %q): want merged at once on the old verdict (%s)", w.forge.merges, + w.op.count(), d.State, d.Ready.Waits, w.logs()) + } + }, + // §5: a failure after the Approve, a carry's, is said once on the pull request and as one stalled line naming the merge; + // it refuses nothing, and a later pass clears the line. + "a failure after the Approve is said once and refuses nothing": func(t *testing.T) { + w := newWorld(t) + id := approvedLone(w) + w.settleAll() + if len(w.forge.merges) != 1 { + t.Fatalf("merges %v: want merged at once", w.forge.merges) + } + before := len(w.forge.comments) + w.h.CommitStatus(sayingFailed(oneTrunk2)) + w.settleAll() + w.h.CommitStatus(sayingFailed(oneTrunk2)) + w.settleAll() + said := 0 + for _, c := range w.forge.comments[before:] { + if strings.Contains(c, "failed after the Approve") { + said++ + } + } + if said != 1 { + t.Fatalf("said %d times on the pull request, want once: %v", said, w.forge.comments[before:]) + } + l := lineOf(w, id) + if l == nil || !strings.Contains(l.Says, "after the operator's Approve") || !strings.Contains(l.Says, oneHead[:12]) { + t.Fatalf("the stalled line = %+v", l) + } + if d := w.delivery(id); d.State == Rejected || len(w.forge.merges) != 1 { + t.Fatalf("%s, merges %v: the failure refused the approved merge", d.State, w.forge.merges) + } + w.h.CommitStatus(checkStatus(oneRepo, oneHead, "success", oneTrunk2)) + w.settleAll() + if l := lineOf(w, id); l != nil { + t.Fatalf("a later pass left the stalled line: %+v", l) + } + }, + } { + t.Run(name, run) + } +} 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 00f2b66e8..f23058fa5 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/approval.go @@ -338,9 +338,9 @@ func (h *Holder) endStaleAsks() { h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed")) case !h.headsHold(d, r): h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads")) - case r.Approved == nil && r.AskID != "" && r.AskCheck != "" && !h.checkGates() && h.checkMoved(d, r.AskCheck): - // The check gates no merge, so the ask was made before its verdict: once the check passes or fails (or a pass it - // named is gone), the ask is asked again in words that say so (issue 1733). Running and not started say the same. + case r.Approved == nil && r.AskID != "" && r.AskCheck != "" && h.checkMoved(d, r.AskCheck): + // The check gates the ask, so it was made on a pass: once that pass is gone (a failure, or a check of these heads + // running again), the ask not yet answered is withdrawn in words that say so (novox/hq ADR 0661 §5, ADR 0441 §1). h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "the check at its heads is now: "+h.checkAt(h.askMembers(d)).Words)) case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)): // The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go index 05cceed05..70da4ca6b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/conditions.go @@ -225,9 +225,6 @@ func (h *Holder) nextStall(now time.Time) time.Time { consider(r.RefusedSince.Add(h.answeredRefusalBound(d.Repository))) consider(r.RefusedSince.Add(outOfDateFor)) } - if r.HeldSince != nil { - consider(r.HeldSince.Add(turnBound)) - } } return next } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go index 5b3e5b39e..1cd053de7 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events.go @@ -67,9 +67,7 @@ func (h *Holder) CommitStatus(e ForgeStatusEvent) { h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description) } if e.State != "success" { - if h.checkGates() { - h.withdrawForStatus(e) // while the check gates no merge, its status is shown and takes back no ask - } + h.withdrawForStatus(e) // a status not a success takes back an ask not yet answered (novox/hq ADR 0661 §5) return } repository := strings.ToLower(e.Owner + "/" + e.Repo) diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go index 63609fe7d..c71ad614a 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/events_test.go @@ -196,7 +196,7 @@ func TestNoWaitReadsADescription(t *testing.T) { w.h.mu.Lock() got := []string{state, why} for _, d := range ds { - got = append(got, w.h.readyWaits(d), w.h.ungatedWaits(d)) + got = append(got, w.h.askWaits(d), w.h.approvedWaits(d)) } w.h.mu.Unlock() if i == 0 { diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go index 624a2271a..3452f4c5e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/group_merge.go @@ -43,14 +43,24 @@ const ( // conflict); empty when all hold. forge says the reason is the forge's merge answer rather than a status. An error is a // read that could not be made: the member then counts as not mergeable. func (h *Holder) notASuccess(m memberRead) (says string, forge bool, err error) { + return h.memberRead(m, true) +} + +// notMergeableNow is notASuccess at the merge: the head the Approve covers and the forge's merge answer, and no required +// status read again, for the merge judges the operator's Approve alone (novox/hq ADR 0661 §4). +func (h *Holder) notMergeableNow(m memberRead) (says string, forge bool, err error) { + return h.memberRead(m, false) +} + +func (h *Holder) memberRead(m memberRead, statuses bool) (says string, forge bool, err error) { fc, err := h.Forge.Statuses(m.owner, m.repo, m.number, m.head, m.base) if err != nil { return "", false, err } - if fc.Merge.Mergeable == nil { + if statuses && fc.Merge.Mergeable == nil { return "", false, fmt.Errorf("the forge could not say whether %s may merge: %s", m.id, fc.Merge.Says) } - if !*fc.Merge.Mergeable { + if statuses && !*fc.Merge.Mergeable { var parts []string for _, b := range fc.Merge.Blocking { parts = append(parts, fmt.Sprintf("%s (%s)", b.Context, b.State)) @@ -82,8 +92,8 @@ func (h *Holder) unmergedLive(g *Group) []*Delivery { return out } -// groupMergeable reads every member's required statuses, its head and the forge's merge answer at the head the Approve -// covers, and says whether all hold: no member is merged unless every member merges now (issue 1275, 2026-10-26: two of +// groupMergeable reads every member's head and the forge's merge answer at the head the Approve covers, no required status +// (novox/hq ADR 0661 §4), and says whether all hold: no member is merged unless every member merges now (issue 1275, 2026-10-26: two of // three merged and the third refused). When one does 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. A read that could not be made is a failed ask, tried again after askAgainAfter; the forge saying a @@ -97,7 +107,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool { } } for _, m := range reads { - says, forge, err := h.notASuccess(m) + says, forge, err := h.notMergeableNow(m) if err == nil && says == "" { continue } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go index e9a896a8d..d6071043b 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/helpers_test.go @@ -21,12 +21,6 @@ func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEven return verdict(repo, number, sha, "pass") } -// ungated is the setting check-gates-merge false: mesh/check gates no merge (the operator, 2026-10-25 22:56). -func ungated(w *world) { - off := false - w.h.Settings = func() Settings { return Settings{CheckGatesMerge: &off} } -} - func statusOn(w *world, repo, commit string) string { w.forge.mu.Lock() defer w.forge.mu.Unlock() 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 9ba599e69..a427ebdad 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/holder.go @@ -85,7 +85,7 @@ type Holder struct { // unchecked are the open pull requests' heads seen without a merge check (unchecked.go). unchecked map[string]unchecked // checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them - // (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go). + // (onestatus.go, issue 1302); runningSince when this owner first saw each running, said in the check's words. checker map[string]checkerVerdict runningSince map[string]time.Time // unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275, @@ -93,11 +93,6 @@ type Holder struct { unmergeableSince map[string]time.Time // writeReads is whether the forge's merging account writes each repository, as last read (writes.go, issue 1945). writeReads map[string]*writeRead - // Turns is the gate's bucket turns (turns.go, ADR 0548 §3); nil keeps none. turnsHeld are the turns as written there, - // by repository, read once at the first look (turnsLoaded). - Turns TurnStore - turnsHeld map[string]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]viewstate.Reach @@ -152,7 +147,7 @@ func (h *Holder) init() { h.lostRead = map[string]time.Time{} h.unchecked = map[string]unchecked{} h.checker = map[string]checkerVerdict{} - h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]Turn{} + h.runningSince = map[string]time.Time{} h.reach = map[string]viewstate.Reach{} h.started = h.Now() } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go index d7bdda30b..ea092f7fa 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_test.go @@ -56,29 +56,16 @@ func urgentLines(w *world) []StalledLine { return out } -// 1275/AC2: an approved group whose member has a required status that is not a success at its head when the merge starts -// is merged not at all, though the first member in order is fine; the group says which member and which status. -func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing(t *testing.T) { +// novox/hq ADR 0661 §4 (changing 1275/AC2 and ADR 0441 §2): an approved group whose member's required status turned +// failed after the Approve merges whole, with no read of its statuses at the merge, and raises no line. +func TestAnApprovedGroupWhoseMemberStatusTurnedAfterTheApproveMergesWhole(t *testing.T) { w := approvedGroup(t) allGreen(w) w.op.answer(w.op.open()[0], OptionApprove) setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing after the ask w.settleAll() - if len(w.forge.merges) != 0 { - t.Fatalf("merged with a required status missing: %v", w.forge.merges) - } - lines := urgentLines(w) - if len(lines) != 1 || !strings.Contains(lines[0].Says, "feat/g") || !strings.Contains(lines[0].Says, IDOf(gCtl, gHc)) || - !strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") { - t.Fatalf("the held group is not said as one urgent line naming group, member, status and need: %+v", lines) - } - // The status turns success: the whole group merges. - setChecks(w, gCtl, gHc, "success", "success") - w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"}) - w.later(time30s) - w.settleAll() if len(w.forge.merges) != 2 || len(urgentLines(w)) != 0 { - t.Fatalf("merges %v, lines %+v; wanted both merged and no line", w.forge.merges, urgentLines(w)) + t.Fatalf("merges %v, lines %+v; wanted both merged on the Approve and no line", w.forge.merges, urgentLines(w)) } } @@ -142,33 +129,6 @@ func TestAStatusThatTurnsWhileTheAskWaitsWithdrawsItAndItsAnswerMergesNothing(t } } -// 1275/AC3 (review round 1): a member that is both refused and held for a required status is said by one stalled line, -// not two with the same id. -func TestAMemberBothRefusedAndHeldForAStatusIsOneStalledLine(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() - setChecks(w, gCat, gHk, "success", "") // now its status is missing too; the next pass is held - w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-catalog", SHA: gHk, Context: repoCheck, State: "success"}) - w.h.mergeAgainNow(w.delivery(IDOf(gCat, gHk))) - w.later(time30s) - w.settleAll() - if g := w.h.groups["feat/g"]; g.Hold == nil { - t.Fatalf("the group was not held for the missing status") - } - n := 0 - for _, l := range w.h.Stalled() { - if l.ID == IDOf(gCat, gHk) { - n++ - } - } - if n != 1 { - t.Fatalf("%d stalled lines for one member: %+v", n, w.h.Stalled()) - } -} - // 1275/AC6, as novox/hq ADR 0505 §4 changed it: a member refused because its verdict is out of date (the trunk moved; its // fresh check runs) is no stalled line while the gate answers it, and urgent once ten minutes passed with the group not // merged; the pass stops at the refusal all the same. @@ -505,7 +465,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) { 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 + moveForgeHead(w, gCtl, 1, otherHead) // the second member's head moved at the forge: the pass stops at it w.settleAll() for range 3 { w.later(time30s) @@ -522,7 +482,7 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) { !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") + moveForgeHead(w, gCtl, 1, gHc) 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 @@ -770,3 +730,13 @@ func bridgeMerged(w *world, repository string, number int, head, merge string) { w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root", Trees: map[string]string{"app": "tree"}}) } + +// moveForgeHead sets the head the forge says a pull request has, as a push the gate has not heard yet does. +func moveForgeHead(w *world, repo string, number int, head string) { + w.forge.mu.Lock() + defer w.forge.mu.Unlock() + key := fmt.Sprintf("%s#%d", repo, number) + p := w.forge.pulls[key] + p.HeadSHA = head + w.forge.pulls[key] = p +} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_whole_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_whole_test.go index 0000692ac..eedf02500 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_whole_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1275_whole_test.go @@ -135,62 +135,47 @@ func handedWords(ps []MergePull) string { // Two approved merges into one repository: the one whose pass started has the repository until every member merged, and // the other is handed only then (2026-10-26: mesh-delivery#72 of feat/890-assignments merged between fix/734-lost-ask's -// members). This holds while mesh/check gates no merge too (ADR 0551): it waits for a merge, never for a check. +// members). It waits for a merge, never for a check: once the group's pass ended #72 is handed on its Approve, its verdict +// gone out of date with #73's merge or not (novox/hq ADR 0661 §3). func TestTwoApprovedMergesIntoOneRepositoryDoNotInterleave(t *testing.T) { - for _, gates := range []bool{true, false} { - t.Run(fmt.Sprintf("check-gates-merge %v", gates), func(t *testing.T) { - w := approvedGroupOf(t, lostAsk...) - if !gates { - ungated(w) - } - w.merges.manual = true - allGreenFor(w, lostAsk...) - w.op.answer(w.op.open()[0], OptionApprove) - w.settleAll() - if got := handedWords(handing(w)); got != "novox/mesh-sdk#92" { - t.Fatalf("handed %q, wanted the group's first member", got) - } - // feat/890-assignments: a lone pull request into mesh-delivery, approved while the group's pass is under way. - w.h.PullUpdated(pr(wDel, 72, wH72, "feat/890-assignments", "modules/mesh-delivery-checker/go.mod")) - w.h.Checked(judged(wDel, 72, wH72, "pass", gTrunk0)) - setChecks(w, wDel, wH72, "success", "success") - w.settleAll() - if open := w.op.open(); len(open) != 1 { - t.Fatalf("the lone pull request is asked once: %v", open) - } else { - w.op.answer(open[0], OptionApprove) - } - w.settleAll() - var order []string - steps := []struct { - repo string - number int - head string - }{{wSDK, 92, wH92}, {wCtl, 472, wH472}, {wDel, 73, wH73}} - if !gates { - // While the check gates the merge, #72's verdict went out of date with #73's merge and it waits for its - // check against the new trunk; while it gates none, #72 is handed once the group's pass ended. - steps = append(steps, struct { - repo string - number int - head string - }{wDel, 72, wH72}) - } - for _, next := range steps { - handed := handing(w) - if len(handed) != 1 || handed[0].Repository != next.repo || handed[0].Number != next.number { - t.Fatalf("after %v: handed %q, wanted only %s#%d", order, handedWords(handed), next.repo, next.number) - } - merge := strings.Repeat(string(rune('a'+next.number%20)), 40) - bridgeMerged(w, next.repo, next.number, next.head, merge) - w.h.PullMerged(merged(next.repo, next.number, next.head, merge)) - w.settleAll() - order = append(order, fmt.Sprintf("%s#%d", next.repo, next.number)) - } - if left := handing(w); len(left) != 0 { - t.Fatalf("still handed: %q", handedWords(left)) - } - }) + w := approvedGroupOf(t, lostAsk...) + w.merges.manual = true + allGreenFor(w, lostAsk...) + w.op.answer(w.op.open()[0], OptionApprove) + w.settleAll() + if got := handedWords(handing(w)); got != "novox/mesh-sdk#92" { + t.Fatalf("handed %q, wanted the group's first member", got) + } + // feat/890-assignments: a lone pull request into mesh-delivery, approved while the group's pass is under way. + w.h.PullUpdated(pr(wDel, 72, wH72, "feat/890-assignments", "modules/mesh-delivery-checker/go.mod")) + w.h.Checked(judged(wDel, 72, wH72, "pass", gTrunk0)) + setChecks(w, wDel, wH72, "success", "success") + w.settleAll() + if open := w.op.open(); len(open) != 1 { + t.Fatalf("the lone pull request is asked once: %v", open) + } else { + w.op.answer(open[0], OptionApprove) + } + w.settleAll() + var order []string + steps := []struct { + repo string + number int + head string + }{{wSDK, 92, wH92}, {wCtl, 472, wH472}, {wDel, 73, wH73}, {wDel, 72, wH72}} + for _, next := range steps { + handed := handing(w) + if len(handed) != 1 || handed[0].Repository != next.repo || handed[0].Number != next.number { + t.Fatalf("after %v: handed %q, wanted only %s#%d", order, handedWords(handed), next.repo, next.number) + } + merge := strings.Repeat(string(rune('a'+next.number%20)), 40) + bridgeMerged(w, next.repo, next.number, next.head, merge) + w.h.PullMerged(merged(next.repo, next.number, next.head, merge)) + w.settleAll() + order = append(order, fmt.Sprintf("%s#%d", next.repo, next.number)) + } + if left := handing(w); len(left) != 0 { + t.Fatalf("still handed: %q", handedWords(left)) } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go index a73e68967..fc25f9384 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/issue1348_test.go @@ -135,18 +135,15 @@ func TestTheBridgesRefusalHandsNoMemberAndIsSaidUrgent(t *testing.T) { } } -// ADR 0441 §2: before a member is handed, every member's required statuses are read again; one that is not a success holds -// the group, even after the first member merged. -func TestTheNextMemberIsNotHandedWhileARequiredStatusOfTheGroupIsNotASuccess(t *testing.T) { +// novox/hq ADR 0661 §4 (changing ADR 0441 §2): the next member is handed on the Approve covering its head, with no read of +// the group's required statuses, after the first member merged. +func TestTheNextMemberIsHandedWithNoReadOfTheGroupsRequiredStatuses(t *testing.T) { w := answeredFirst(t) w.merges.say(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: gCat, Number: 2, Head: gHk, Merge: strings.Repeat("a", 40), Root: "r", Trees: map[string]string{"x": "t"}}) setChecks(w, gCtl, gHc, "success", "") // the second member's repository check went missing meanwhile w.settleAll() - if len(w.merges.puts) != 1 { - t.Fatalf("the second member was handed with a required status missing: %d merges written", len(w.merges.puts)) - } - if lines := urgentLines(w); len(lines) != 1 || !strings.Contains(lines[0].Says, "mesh/repo-check (missing)") { - t.Fatalf("the held group is not said urgent: %+v", lines) + if len(w.merges.puts) != 2 || len(urgentLines(w)) != 0 { + t.Fatalf("%d merges written, lines %+v: wanted the second member handed on its Approve", len(w.merges.puts), urgentLines(w)) } } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go index c610b4c34..0bd8150cc 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/keeps.go @@ -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: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}, {Name: "turns", 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...) } 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 8f1721dfd..468c75217 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/main.go @@ -66,7 +66,6 @@ func main() { Operator: kvOperator{}, Merges: kvMerges{}, Source: kvSource{}, - Turns: kvTurns{}, Owed: seatOwed{say: stdio.SeatPublish}, Pulls: kvPulls{}, // The gate says what stalled to the operator itself (issue 1275, ADR 0437 §9). @@ -435,24 +434,6 @@ func tools(h *Holder, l *listening) []stdio.Tool { } 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 " + - "turns yet (the checker claims no check of a repository a turn names), and the bound past which a held merge is a stalled " + - "line. Read-only.", - Run: func(map[string]any) (any, error) { return h.TurnsRead(), nil }}, - {Name: "delivery_hand_now", - Description: "On the operator's word, hand an approved merge held for another delivery's running check to the merge at once " + - "(novox/hq ADR 0548 §4): the check it waited for runs on and is carried to the new trunk after the merge. Refused for a " + - "delivery that is not an approved merge held by a check, naming why. The why is kept on the delivery.", - Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("the delivery's id (owner/repository@commit)"), - "why": str("the operator's word, kept on the delivery")}, "required": []string{"id", "why"}}, - Run: func(a map[string]any) (any, error) { - if err := need(a, "id", "why"); err != nil { - return nil, err - } - return h.HandNow(strArg(a, "id"), strArg(a, "why")) - }}, {Name: "delivery_merge_again", Description: "On the operator's word, ask again at once the merge of an approved delivery the forge refused, or of an approved " + "group held or half merged, at the heads the Approve named (novox/hq issue 1275): every member is read first and " + 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 16147a69d..dcc245082 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 @@ -120,12 +120,20 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) { if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) { t.Fatalf("emits %v", m.Emits) } - // Nine buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3, the conditions it raised (issue 1275) and the - // merge turns (ADR 0548 §3). + // Eight buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3 and the conditions it raised (issue 1275). No + // `turns`, no turn tool and no `check-gates-merge`: no check gates a merge (novox/hq ADR 0661 §1, §2, §6). buckets := m.buckets() - if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source", "conditions", "turns"}) { + if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source", "conditions"}) { t.Fatalf("state %v", buckets) } + for _, tool := range m.Tools { + if strings.Contains(tool, "turn") || strings.Contains(tool, "hand_now") { + t.Fatalf("the gate lists the turn tool %s", tool) + } + } + if raw, err := os.ReadFile("../../module.json"); err != nil || strings.Contains(string(raw), "check-gates-merge") { + t.Fatalf("the manifest still names check-gates-merge (%v)", err) + } // 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). 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. 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 8f69a2209..6ff3e41e3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/model.go @@ -164,6 +164,13 @@ func (v *Verdict) Judged() string { // outOfDate is why a delivery's verdict no longer counts: it names a trunk commit that is not its trunk's head // as this owner heard it (novox/hq ADR 0323 §7). Empty when it counts — or names no trunk commit, as a verdict // of a build seat from before the rule does, which counts as it did. +// approvedAtHead is whether the operator's Approve covers this delivery at its head: once it does, the merge judges that +// Approve alone, not the check (novox/hq ADR 0661 §3, §4). +func (d *Delivery) approvedAtHead() bool { + r := d.Ready + return r != nil && r.Approved != nil && !r.declined() && r.Heads[d.ID] == d.Commit +} + func (d *Delivery) outOfDate() string { if d.Check == nil || d.Check.Trunk == "" || d.TrunkNow == "" || sameCommit(d.Check.Trunk, d.TrunkNow) { return "" diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go index 72cd0e552..554ad13f3 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/onestatus.go @@ -97,7 +97,7 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) { v.Heads = hash } if prev, ok := h.checker[v.Heads]; v.State == checkerRunning && (!ok || prev.State != checkerRunning) { - h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2) + h.runningSince[v.Heads] = h.Now() // a check starts: its words say since when } else if v.State != checkerRunning { delete(h.runningSince, v.Heads) } @@ -171,10 +171,12 @@ func (h *Holder) takeCheckStatus(repository, sha, state, desc string) { } h.moved(d, []Transition{t}) } - if d.State == Ready && !h.checkGates() { - // The check gates no merge (check-gates-merge false): its verdict is shown on the delivery, and moves nothing. + if d.approvedAtHead() && (d.State == Ready || d.State == Merged) { + // The merge judges the operator's Approve alone (novox/hq ADR 0661 §3): a verdict after it, a carry's, is shown and + // moves nothing; a failure is said (failedAfterApprove). d.Check = v - h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (shown; the check gates no merge)", d.ID, state, oneLine(clip(desc, 120))) + h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (after the operator's Approve)", d.ID, state, oneLine(clip(desc, 120))) + h.failedAfterApprove(d, v) h.keep(d) return } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go index 37186e644..f3da1acf1 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready.go @@ -128,12 +128,11 @@ type ReadyRun struct { Warrant string `json:"warrant,omitempty"` Refused string `json:"refused,omitempty"` - // One check per delivery (novox/hq ADR 0548 §2, §4, turns.go): HeldSince is when the approved merge began to wait for the - // check of other heads HeldFor names (its verdict key), running on a repository it goes into; HandNow the operator's word - // that handed it at once past that check. - HeldSince *time.Time `json:"held_since,omitempty"` - HeldFor string `json:"held_for,omitempty"` - HandNow string `json:"hand_now,omitempty"` + // FailedAfter is a failure heard after the operator's Approve covered the head, a carry's (novox/hq ADR 0661 §5): said + // once on the pull request and as a stalled line naming the merge it follows, never a refusal of the merge; cleared by a + // later pass. FailedAfterSince is when it was heard. + FailedAfter string `json:"failed_after,omitempty"` + FailedAfterSince *time.Time `json:"failed_after_since,omitempty"` // CannotWrite is why the operator is not asked: the forge's merging account cannot write a repository the ask covers // (writes.go, issue 1945); CannotWriteSince since when. Said at once as a condition. @@ -194,7 +193,6 @@ func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun { func (h *Holder) readyAsks(now time.Time) []func() { var asks []func() h.endStaleAsks() - gates := h.checkGates() ids := make([]string, 0, len(h.deliveries)) for id := range h.deliveries { ids = append(ids, id) @@ -205,9 +203,6 @@ func (h *Holder) readyAsks(now time.Time) []func() { if d.Number == 0 || d.State.Final() || d.MergedAs != "" { continue // not of a pull request, or past its merge } - if !gates { - h.ungate(d, now) - } if d.Ready == nil && d.State == Ready { h.runOf(d, now) h.Poke() // its check, if none is asked, is worth asking now (askCheck) @@ -276,9 +271,6 @@ func (h *Holder) readyAsks(now time.Time) []func() { if waits == "" { waits, approvalAsk = h.approval(d, now) } - if waits == "" && gates { - waits = h.turnWaits(d, now) // nothing merges into a repository while a check of other heads judges it (ADR 0548 §2) - } if waits == "" { waits = h.passWaits(d) // no merge lands in a repository between another approved group's members (issue 1275) } @@ -310,7 +302,6 @@ func (h *Holder) readyAsks(now time.Time) []func() { h.noteNotHanded(d, h.notHanded(g, d), now) // a merge never handed says why (2026-10-29: silent for an hour) } } - asks = append(asks, h.turnWrites()...) // the merge turns, after the holds of this look (ADR 0548 §3) asks = append(asks, h.settleMerges()...) asks = append(asks, h.settleForgeMerges()...) asks = append(asks, h.recordForgeMade()...) @@ -321,30 +312,6 @@ func (h *Holder) readyAsks(now time.Time) []func() { return asks } -// ungate stands a delivery where it is while mesh/check gates no merge (check-gates-merge false): a proposed one is ready -// with no verdict, and a rejected one, refused on a verdict before the setting changed, is proposed again and so ready. Its -// verdict stays on it, shown. Under the lock. -func (h *Holder) ungate(d *Delivery, now time.Time) { - f := h.factsAt(now) - if d.State == Rejected { - f.By, f.Why = byOwner, "the check gates no merge (check-gates-merge false): its verdict is shown, not waited on" - t, err := Apply(d, EvRecheck, true, f) - if err != nil { - h.refuse(err) - return - } - if d.Ready != nil { - d.Ready.Failed = "" - } - h.moved(d, []Transition{t}) - f.By, f.Why = "", "" - } - if d.State == Proposed { - h.moved(d, settle(d, f)) - h.closeGroupOf(d) - } -} - // sayAfterLine tells a pull request, once, that its description holds an `after: ` line, which is not read // (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line. // Once per pull request, not per head. Under the lock; the comment is said outside it. @@ -406,14 +373,21 @@ func (r *ReadyRun) noteRefusal(d *Delivery, answer string) { r.Repeats, r.RepeatTrunk = 1, d.TrunkNow } -// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. A ready -// delivery is asked. So is a proposed one whose verdict went out of date, when an Approve was asked for it already (it -// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an -// Approve waits for that check first. +// readyWaits is what a delivery's merge waits on; empty when the Approve may be asked and the merge asked. Once the +// operator's Approve covers its heads the merge judges that Approve alone (approvedWaits); until then the check gates the +// ask (askWaits), the one place a check gates anything here (novox/hq ADR 0661 §3, §4). Under the lock. func (h *Holder) readyWaits(d *Delivery) string { - if !h.checkGates() { - return h.ungatedWaits(d) + if c := h.covering(d); c != nil && c.Approved != nil { + return h.approvedWaits(d) } + return h.askWaits(d) +} + +// askWaits is why the operator's Approve is not asked yet: the check gates the ask (novox/hq ADR 0661 §3, ADR 0421 §1). A +// ready delivery is asked once the checker's verdict at exactly the heads the ask covers passed; a proposed one whose +// verdict went out of date while its ask is pending waits for its check against the trunk's head. A group is asked only +// once its composed check passed at its members' heads (ADR 0661 §4). +func (h *Holder) askWaits(d *Delivery) string { if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil { return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head" } @@ -421,10 +395,6 @@ func (h *Holder) readyWaits(d *Delivery) string { case d.State == Ready: case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain != nil: return "its check against the trunk's head is asked and has not answered" - case d.State == Proposed && d.outOfDate() != "" && d.Check.Passes() && h.covering(d) != nil: - // Approved, and its trunk moved: the checker carries its verdict to the trunk's head by its package digests (ADR 0548 - // §5, §8). The Approve stands and is not asked again; the merge waits for the carried verdict, not a fresh check. - return "approved; its verdict is being carried to the trunk's head " + shortOf(d.TrunkNow, 12) + " (" + d.outOfDate() + ")" case d.State == Proposed && d.outOfDate() != "": return "its verdict is out of date: its check against the trunk's head is asked before the operator is" case d.State == Proposed && d.AskedAgain != nil: @@ -442,10 +412,16 @@ func (h *Holder) readyWaits(d *Delivery) string { return h.groupTurnToMerge(d) } -// ungatedWaits is readyWaits while mesh/check gates no merge (check-gates-merge false): a ready delivery waits for nothing but -// a group member for every member to be ready; no verdict, composed check or trunk is waited on, -// so the Approve is asked at once. -func (h *Holder) ungatedWaits(d *Delivery) string { +// approvedWaits is what a merge the operator's Approve covers waits on: nothing of the check (novox/hq ADR 0661 §3, §4). +// No verdict, carry, composed check or trunk move is waited on, and no status is read again; a group member waits only +// for every member to be ready, at the heads the Approve covers, so the group merges in one pass. A proposed delivery +// the Approve covers (a trunk moved before the Approve took its run) is ready on it. Under the lock. +func (h *Holder) approvedWaits(d *Delivery) string { + if c := h.covering(d); d.State == Proposed && d.Ready != nil && c != nil && c.Approved != nil { + now := h.Now() + h.approval(d, now) // the covering Approve taken onto its own run, so the table reads it + h.moved(d, settle(d, h.factsAt(now))) + } if d.State != Ready { return "it is " + string(d.State) + ", not ready yet" } @@ -456,9 +432,6 @@ func (h *Holder) ungatedWaits(d *Delivery) string { } } } - // No verdict is waited on (the operator, 2026-11-07: "we aren't running any checks anymore, we're delivering"): while - // the check gates no merge, the Approve is asked once the delivery is ready; a verdict that comes later - // is shown on the pull request and the delivery, as before. return "" } @@ -517,12 +490,50 @@ func (h *Holder) sayOn(d *Delivery, body string) func() { return func() {} } +// failedAfterApprove says a failure heard after the operator's Approve covered the head: once on the pull request, and +// as the stalled line readyLines lists; the merge is not refused for it, and the operator decides the remedy, a fix or a +// revert (novox/hq ADR 0661 §5). A later pass clears it. Under the lock. +func (h *Holder) failedAfterApprove(d *Delivery, v *Verdict) { + r := d.Ready + if r == nil { + return + } + if v.Passes() { + r.FailedAfter, r.FailedAfterSince = "", nil + return + } + if r.FailedAfter != "" { + return + } + now := h.Now() + r.FailedAfter, r.FailedAfterSince = "its check failed after the operator's Approve: "+oneLine(clip(v.Summary, 120)), &now + h.Logf("[mesh-delivery] %s (%s): %s", d.ID, h.mergeFollowed(d), r.FailedAfter) + h.sayOn(d, fmt.Sprintf("mesh-delivery: **failed after the Approve.** This pull request (%s) %s. The merge is not "+ + "refused for it: the operator decides the remedy, a fix or a revert.", h.mergeFollowed(d), r.FailedAfter)) +} + +// mergeFollowed names the merge a failure after the Approve follows: the trunk commit it merged as, or its approved head +// not merged yet. +func (h *Holder) mergeFollowed(d *Delivery) string { + if d.MergedAs != "" { + return "merged as " + shortOf(d.MergedAs, 12) + " at its head " + shortOf(d.Commit, 12) + } + return "approved at its head " + shortOf(d.Commit, 12) + ", not merged yet" +} + // 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 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 { r := d.Ready + if r != nil && r.FailedAfter != "" && r.FailedAfterSince != nil && d.State != Closed { + out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, + For: now.Sub(*r.FailedAfterSince).Round(time.Second).String(), Bound: "0s", + H2: "none: the operator decides the remedy, a fix or a revert; a later pass clears it", + Says: fmt.Sprintf("%s#%d (%s): %s", d.Repository, d.Number, h.mergeFollowed(d), r.FailedAfter)}) + continue + } if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() { continue } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go index 6c80aec72..2a00b03f2 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/ready_test.go @@ -8,7 +8,6 @@ import ( "time" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" - mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" ) // **A pull request whose checks passed and whose merge the operator approved is merged by this owner** (novox/hq issues @@ -85,22 +84,9 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { {name: "gated, passed", gated: true, state: "pass", asks: 1, head: "Merge into main: app#7 Fix it", want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends", "no verdict yet"}}, - // The operator, 2026-11-07: "we aren't running any checks anymore, we're delivering": while the check gates no - // merge the Approve is asked at once, with no verdict or a running check, never waiting for one. - {name: "ungated, no verdict", asks: 1, not: []string{waitsCheckBeforeAsking}}, - {name: "ungated, running", state: "running", asks: 1, not: []string{waitsCheckBeforeAsking}}, - // Asked at once, then asked again in its words once the verdict comes (a later verdict asks a pending Approve again). - {name: "ungated, passed", state: "pass", asks: 2, - want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}}, - {name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 2, - head: "Check failed \u2014 merge into main: app#7 Fix it", - want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}}, } { t.Run(row.name, func(t *testing.T) { v := newWorld(t) - if !row.gated { - ungated(v) - } v.op.auto = false p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go") p.Title = "Fix it" @@ -158,47 +144,6 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) { } }) } - - // check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another - // merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's - // status and show say the mode. - u := newWorld(t) - ungated(u) - u.op.auto = false - const other = "3333333333333333333333333333333333333333" - u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go")) - u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go")) - u.settleAll() - if u.op.count() == 0 || u.state(IDOf("novox/app", readyHead)) != Ready { - t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s; wanted ready and asked at once", u.op.count(), u.state(IDOf("novox/app", readyHead))) - } - if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates { - t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening")) - } - shown, _ := u.h.Show(IDOf("novox/app", other)) - if gates, _ := shown.(map[string]any)["check-gates-merge"].(bool); gates { - t.Fatalf("show does not say the check gates no merge: %v", shown) - } - u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1")) - checkerSays(u, "feat/y", "fail", "TestB", headOf("novox/app", 8, other)) - u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7")) - u.settleAll() - if u.op.count() == 0 { - t.Fatalf("ungated, a failed verdict holds the ask (the check gates no merge): %d asks", u.op.count()) - } - d = u.delivery(IDOf("novox/app", other)) - if d.State != Ready || d.Check == nil || d.Check.Passes() { - t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check) - } - for _, a := range u.op.open() { - if strings.Contains(a, "feat/y") { - u.op.answer(a, OptionApprove) - } - } - u.settleAll() - if len(u.forge.merges) != 1 || u.forge.merges[0] != "novox/app#8@"+other { - t.Fatalf("ungated, the approved head is merged %v; wanted once, with no verdict waited on", u.forge.merges) - } } // 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a @@ -342,57 +287,6 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing. if w.op.count() != 1 { t.Fatalf("a second Approve was asked: %d", w.op.count()) } - - // check-gates-merge false: the group is asked once with no verdict and no composed check, and merges whole in one pass. - u := newWorld(t) - ungated(u) - u.op.auto = false - u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) - u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) - u.settleAll() - group := []mdc.Head{headOf("novox/mesh-controller", 1, ctlHead), headOf("novox/mesh-catalog", 2, catHead)} - checkerSays(u, "feat/x", "running", "", group...) - u.settleAll() - // Issue 1966 (the operator, 2026-11-07): the group is asked at once, its check running or not; nothing waits on it. - if d := u.delivery(IDOf("novox/mesh-catalog", catHead)); u.op.count() != 1 || d.Ready == nil || - strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) { - t.Fatalf("ungated, the group is asked %d times while its check runs; wanted once at once, and the wait said: %+v", u.op.count(), d.Ready) - } - checkerSays(u, "feat/x", "pass", "", group...) - u.settleAll() - if u.op.count() != 2 { - t.Fatalf("ungated, the group is asked %d times once its check passed; wanted once at once and once again in its words", u.op.count()) - } - if e := u.op.ask(u.op.open()[0]).Explanation; !strings.Contains(e, "2 pull requests, passed its check. Approve merges all of them into main in one pass, at the heads named;") { - t.Fatalf("ungated, the group's ask does not say its check passed:\n%s", e) - } - // A member re-pushed hours after its sibling became ready: with no verdict at the new heads nothing is asked, however - // long the sibling has waited. - r := newWorld(t) - ungated(r) - r.op.auto = false - const ctlHead2 = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go")) - r.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")) - r.settleAll() - r.later(5 * time.Hour) - r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead2, "feat/x", "internal/a.go")) - // Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads. - r.h.mu.Lock() - r.h.ungate(r.h.deliveries[IDOf("novox/mesh-controller", ctlHead2)], r.h.Now()) - r.h.mu.Unlock() - r.settleAll() - // Issue 1966: no verdict is waited on, so the new heads are asked again at once, never held for a check. - if d := r.delivery(IDOf("novox/mesh-catalog", catHead)); r.op.count() == 0 || d.Ready == nil || - strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) { - t.Fatalf("a member re-pushed hours later: asked %d times, wait %+v; wanted asked again at once", r.op.count(), d.Ready) - } - - u.op.answer(u.op.open()[0], OptionApprove) - u.settleAll() - if len(u.forge.merges) != 2 { - t.Fatalf("ungated, both members merge in one pass: %v", u.forge.merges) - } } // **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go index c3217ae2e..50b905024 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/settings.go @@ -25,10 +25,6 @@ const ( type Settings struct { RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"` AuthorBoundMinutes *int `json:"author-new-head-minutes"` - // CheckGatesMerge is whether mesh/check gates the merge (the default, true). False (the operator, 2026-10-25 22:56: - // "run the check locally only, but not as a gate, it loses us too much time"): a reviewed pull request is ready and its - // Approve asked without a verdict, a trunk's move puts nothing out of date, and a verdict is shown, never waited on. - CheckGatesMerge *bool `json:"check-gates-merge"` // RecordRetentionDays is how many days a delivery record is kept from when it opened (design 47 *The delivery // record*); nil keeps DefaultRecordRetention. RecordRetentionDays *int `json:"record-retention"` @@ -73,28 +69,13 @@ func readSettings(path string) (Settings, error) { default: return Settings{}, errors.New("record-retention is not a number") } - switch v := loose["check-gates-merge"].(type) { - case nil: - case bool: - s.CheckGatesMerge = &v - default: - return Settings{}, errors.New("check-gates-merge is not true or false") - } return s, nil } -// checkGates is whether mesh/check gates the merge: true unless the setting check-gates-merge is false. -func (h *Holder) checkGates() bool { - if h.Settings == nil { - return true - } - v := h.Settings().CheckGatesMerge - return v == nil || *v -} - -// factsAt are the facts a settle reads at now: the moment, and whether the check gates the merge. +// factsAt are the facts a settle reads at now: the moment. No setting switches whether a check gates a merge: the check +// gates the ask, and the merge judges the operator's Approve alone (novox/hq ADR 0661 §2, §3). func (h *Holder) factsAt(now time.Time) Facts { - return Facts{Now: now, Ungated: !h.checkGates()} + return Facts{Now: now} } // minutesOr is a setting's minutes, or the default when it gives none. 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 bfc56be93..771116f70 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/table.go @@ -94,9 +94,6 @@ type Facts struct { Now time.Time By string Why string - // Ungated is the setting check-gates-merge false: mesh/check gates no merge, so a proposed delivery is accepted with no - // verdict, none is refused for one, and a trunk's move puts none back to proposed. - Ungated bool } // Row is one transition the table holds. @@ -145,10 +142,10 @@ var Table = []Row{ // cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again. {From: []State{Proposed}, Event: EvAccepted, To: Ready, Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " + - "the repository's own check did not fail; or the check gates no merge (check-gates-merge false)", + "the repository's own check did not fail; or the operator's Approve covers this head (the merge judges it alone)", Holds: func(d *Delivery, f Facts) error { - if f.Ungated { - return nil + if d.approvedAtHead() { + return nil // novox/hq ADR 0661 §3: an Approve given stands across a trunk move, and the merge judges it alone } if d.Check == nil { return errors.New("no verdict") @@ -160,10 +157,11 @@ var Table = []Row{ }}, {From: []State{Proposed}, Event: EvRefused, To: Rejected, Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " + - "or the repository's own check failed; never while the check gates no merge", + "or the repository's own check failed; never once the operator's Approve covers this head", Holds: func(d *Delivery, f Facts) error { - if f.Ungated { - return errors.New("the check gates no merge (check-gates-merge false)") + if d.approvedAtHead() { + // novox/hq ADR 0661 §5: a failure after the Approve is said, and does not undo it. + return errors.New("the operator's Approve covers this head: a failure is said, the merge is not refused") } if d.Check == nil { return errors.New("no verdict") @@ -176,10 +174,11 @@ var Table = []Row{ // **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another // merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date. {From: []State{Ready}, Event: EvTrunkMoved, To: Proposed, - Guard: "its verdict names a trunk commit that is not its trunk's head, and the check gates the merge", + Guard: "its verdict names a trunk commit that is not its trunk's head, and no Approve covers this head yet", Holds: func(d *Delivery, f Facts) error { - if f.Ungated { - return errors.New("the check gates no merge (check-gates-merge false)") + if d.approvedAtHead() { + // novox/hq ADR 0661 §3: an approved merge waits for no carry; its carry runs after the merge. + return errors.New("the operator's Approve covers this head: it merges onto the new trunk, its carry after") } return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date") }, 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 bf92f3067..1469a95a5 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 @@ -161,21 +161,34 @@ func TestTheTableKeepsItsRules(t *testing.T) { if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil { t.Fatal("a check re-asked once was re-asked again") } - // While the check gates no merge (check-gates-merge false): a proposed delivery is accepted with no verdict or a failed - // one, none is refused, and a trunk's move puts no ready one back to proposed. - off := Facts{Now: time.Now(), Ungated: true} + // Once the operator's Approve covers the head (novox/hq ADR 0661 §3, §5): a proposed delivery is accepted with no + // verdict or a failed one, none is refused, and a trunk's move puts no ready one back to proposed. + at := time.Now() + approved := func(d *Delivery) *Delivery { + d.Ready = &ReadyRun{Approved: &at, Heads: map[string]string{d.ID: d.Commit}} + return d + } + f := Facts{Now: at} for _, check := range []*Verdict{nil, {Gate: "fail"}} { - p := &Delivery{ID: "novox/app@c0", State: Proposed, Number: 1, Check: check} - if _, err := Apply(p, EvRefused, false, off); err == nil { - t.Fatalf("ungated, a verdict %+v refused the delivery", check) + p := approved(&Delivery{ID: "novox/app@c0", Commit: "c0", State: Proposed, Number: 1, Check: check}) + if _, err := Apply(p, EvRefused, false, f); err == nil { + t.Fatalf("approved, a verdict %+v refused the delivery", check) } - if _, err := Apply(p, EvAccepted, false, off); err != nil || p.State != Ready { - t.Fatalf("ungated, a delivery with the verdict %+v is %s: %v", check, p.State, err) + if _, err := Apply(p, EvAccepted, false, f); err != nil || p.State != Ready { + t.Fatalf("approved, a delivery with the verdict %+v is %s: %v", check, p.State, err) + } + unapproved := &Delivery{ID: "novox/app@c0", Commit: "c0", State: Proposed, Number: 1, Check: check} + if _, err := Apply(unapproved, EvAccepted, false, f); err == nil { + t.Fatalf("with no Approve, a delivery with the verdict %+v was accepted: the check gates the ask", check) } } - stale := &Delivery{ID: "novox/app@c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"} - if _, err := Apply(stale, EvTrunkMoved, false, off); err == nil { - t.Fatal("ungated, a trunk's move put a ready delivery back to proposed") + stale := approved(&Delivery{ID: "novox/app@c0", Commit: "c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"}) + if _, err := Apply(stale, EvTrunkMoved, false, f); err == nil { + t.Fatal("approved, a trunk's move put a ready delivery back to proposed") + } + stale.Ready = nil + if _, err := Apply(stale, EvTrunkMoved, false, f); err != nil || stale.State != Proposed { + t.Fatalf("with no Approve, a trunk's move left a ready delivery %s: %v", stale.State, err) } // A ready delivery off the trunk is refused its merge, whatever else is true of it. d := &Delivery{ID: "novox/app@c0", State: Ready} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/turns.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/turns.go deleted file mode 100644 index a5bd78f73..000000000 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/turns.go +++ /dev/null @@ -1,461 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - checker "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" - mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// **One check per delivery** (novox/hq ADR 0548 §2 to §4, issue 568). While a check runs, nothing merges into a repository it -// judges, and an approved merge holds a turn on its repositories so no new check starts there: -// -// - the hold (§2): an approved merge is handed to the forge bridge only when no verdict of the checker's verdicts bucket in -// the state running names, among its trunk heads, a repository the merge goes into; the verdict of the merge's own heads -// excepted. The gate watches that bucket already (onestatus.go), so the verdict leaving running gives the waiting merge its -// turn at once, with no timer. Siblings a check reads beside are not held (§2). Only while mesh/check gates the merge; -// - the turn (§3): before it reads the verdicts for a merge, the gate writes one key per repository the merge goes into in its -// bucket turns (`.`, mdc.TurnsKey): the deliveries that wait there in the order of their Approve, the check -// each waits for and since when; for a group on every member's repository until its last member merged. The checker reads -// the bucket and claims no check of a repository a turn names. The key goes when the last delivery in it merged or left; -// - the bound and the tools (§4): a merge held past turnBound is a stalled line; delivery_turns reads every turn and -// delivery_hand_now hands a waiting merge at once on the operator's word, the check it waited for then carried. - -// turnBound is how long an approved merge may wait for another delivery's check before it is a stalled line (ADR 0548 §4). -const turnBound = 30 * time.Minute - -// TurnStore is the gate's bucket turns: one key per repository, written by the gate alone. -type TurnStore interface { - // All reads every turn, by repository (lowercase). - All() (map[string]Turn, error) - Put(t Turn) error - Delete(repository string) error -} - -// kvTurns is the bucket turns over the runtime. -type kvTurns struct{} - -func (kvTurns) All() (map[string]Turn, error) { - keys, err := stdio.State("turns").Keys() - if err != nil { - return nil, err - } - out := map[string]Turn{} - for _, k := range keys { - e, err := stdio.State("turns").Get(k) - if err != nil { - return nil, err - } - if e == nil { - continue - } - repository, err := mdc.ParseTurnsKey(k) - if err != nil { - continue // not a turn's key: never the gate's, left - } - t, err := DecodeTurn(e.Value) - if err != nil { - t = Turn{Repository: repository} // unreadable: rewritten or deleted at the next look - } - out[repository] = t - } - return out, nil -} - -func (kvTurns) Put(t Turn) error { - key, err := mdc.TurnsKey(t.Repository) - if err != nil { - return err - } - raw, err := EncodeTurn(t) - if err != nil { - return err - } - _, err = stdio.State("turns").Put(key, json.RawMessage(raw)) - return err -} - -func (kvTurns) Delete(repository string) error { - key, err := mdc.TurnsKey(repository) - if err != nil { - return err - } - return stdio.State("turns").Delete(key) -} - -// approvedMerge is an approved merge not yet done: the run holding its Approve, the deliveries it covers, the hash of their -// heads as the checker keys their verdict (lowercase, onestatus.go headsOf), and the repositories it goes into (lowercase). -type approvedMerge struct { - owner *Delivery - run *ReadyRun - group string - heads string - repos []string -} - -// approvedMerges are the merges the operator approved that are not done, in the order of their Approve: each ask once, its -// run on the delivery that asked it. A declined, refused, merged, rejected or final one has left. Under the lock. -func (h *Holder) approvedMerges() []approvedMerge { - var out []approvedMerge - seen := map[string]bool{} - ids := make([]string, 0, len(h.deliveries)) - for id := range h.deliveries { - ids = append(ids, id) - } - sort.Strings(ids) // the same owner of an ask at every look, whichever member's run holds it - for _, id := range ids { - d := h.deliveries[id] - r := d.Ready - if r == nil || r.Approved == nil || r.declined() || r.AskID == "" || len(r.Heads) == 0 || seen[r.AskID] || - d.State.Final() || d.State == Rejected || d.Base != MergeBranch || r.RefusedSince != nil || h.allMerged(r.Heads) { - continue - } - seen[r.AskID] = true - var heads []mdc.Head - repos := map[string]bool{} - for id, sha := range r.Heads { - m := h.deliveries[id] - if m == nil { - continue - } - heads = append(heads, mdc.Head{Repository: strings.ToLower(m.Repository), Number: m.Number, Head: strings.ToLower(sha)}) - repos[strings.ToLower(m.Repository)] = true - } - hash, err := mdc.HeadsHash(heads) - if err != nil { - continue - } - am := approvedMerge{owner: d, run: r, group: askGroup(d), heads: hash} - for repo := range repos { - am.repos = append(am.repos, repo) - } - sort.Strings(am.repos) - out = append(out, am) - } - sort.SliceStable(out, func(i, j int) bool { - if !out[i].run.Approved.Equal(*out[j].run.Approved) { - return out[i].run.Approved.Before(*out[j].run.Approved) - } - return out[i].heads < out[j].heads - }) - return out -} - -// mergeOfDelivery is the approved merge covering d, if one is approved. -func (h *Holder) mergeOfDelivery(d *Delivery) (approvedMerge, bool) { - c := h.covering(d) - if c == nil || c.Approved == nil { - return approvedMerge{}, false - } - for _, am := range h.approvedMerges() { - // By the ask, not the run: a group's members may each hold a run of the same ask, and the merge is the ask's. - if am.run.AskID == c.AskID { - return am, true - } - } - return approvedMerge{}, false -} - -// runningCheck is a check of the checker in the state running that names repo among its trunk heads, its heads not own: its -// verdict key and since when the gate has seen it run. False when none runs there. Under the lock. -func (h *Holder) runningCheck(repo, own string) (key string, v checkerVerdict, since time.Time, ok bool) { - hashes := make([]string, 0, len(h.checker)) - for hash := range h.checker { - hashes = append(hashes, hash) - } - sort.Strings(hashes) - for _, hash := range hashes { - v := h.checker[hash] - if v.State != checkerRunning || v.Heads == own { - continue - } - for r := range v.Trunk { - if strings.EqualFold(r, repo) { - key, err := checker.VerdictKey(v.Group, v.Heads) - if err != nil { - key = v.Group + "." + v.Heads - } - return key, v, h.runningSince[v.Heads], true - } - } - } - return "", checkerVerdict{}, time.Time{}, false -} - -// turnWaits is what an approved merge waits for before it is handed (ADR 0548 §2, §3): its turns written, then the end of -// every check of other heads running on a repository it goes into. Empty when it may be handed. It keeps on the run since -// when it is held and for which check, for the stalled line and the tools. Under the lock. -func (h *Holder) turnWaits(d *Delivery, now time.Time) string { - am, ok := h.mergeOfDelivery(d) - if !ok { - return "" - } - r := am.run - if h.Turns != nil { - if !h.turnsLoaded { - return "approved; its merge turn waits for the turns the gate wrote before to be read" - } - for _, repo := range am.repos { - if !turnNames(h.turnsHeld[repo], am.heads) { - return "approved; its merge turn on " + repo + " is being written, so no check starts there before it merges" - } - } - } - if r.HandNow != "" { - if r.HeldSince != nil { - r.HeldSince, r.HeldFor = nil, "" - h.keep(am.owner) - } - return "" // handed now on the operator's word: the check it waited for is carried after the merge - } - for _, repo := range am.repos { - key, v, since, running := h.runningCheck(repo, am.heads) - if !running { - continue - } - if r.HeldSince == nil || r.HeldFor != key { - at := now - r.HeldSince, r.HeldFor = &at, key - h.keep(am.owner) - h.Logf("[mesh-delivery] %s is approved and held: the check %s runs on %s, and nothing merges there while it does (ADR 0548 §2)", am.owner.ID, key, repo) - } - words := fmt.Sprintf("approved; waits for the check of %s (heads %s) on %s", v.Group, v.Heads, repo) - if !since.IsZero() { - words += ", running since " + since.UTC().Format(time.RFC3339) - } - return words + " (novox/hq ADR 0548 §2)" - } - if r.HeldSince != nil { - r.HeldSince, r.HeldFor = nil, "" - h.keep(am.owner) - } - return "" -} - -// turnNames says whether a turn names the heads. -func turnNames(t Turn, heads string) bool { - for _, w := range t.Waiting { - if w.Heads == heads { - return true - } - } - return false -} - -// wantedTurns are the turns the approved merges hold now, by repository: each merge on every repository it goes into, in -// the order of their Approve, with the check of other heads it waits for there. Under the lock. -func (h *Holder) wantedTurns() map[string]Turn { - out := map[string]Turn{} - for _, am := range h.approvedMerges() { - for _, repo := range am.repos { - t := out[repo] - t.Repository = repo - w := TurnWait{Group: am.group, Heads: am.heads, Ask: am.run.AskID, Approved: am.run.Approved.UTC(), Since: am.run.Approved.UTC()} - if key, _, since, ok := h.runningCheck(repo, am.heads); ok { - w.WaitsFor = key - if !since.IsZero() { - w.WaitsSince = since.UTC() - } - } - t.Waiting = append(t.Waiting, w) - out[repo] = t - } - } - return out -} - -// turnWrites are the writes that bring the bucket turns to what the approved merges hold: a turn written where it changed, -// a key deleted where its last delivery merged or left. The first look reads what a gate before this one wrote. Under the -// lock; the writes run outside it, and each that lands is kept and asks for a look. -func (h *Holder) turnWrites() []func() { - if h.Turns == nil { - return nil - } - if !h.turnsLoaded { - return []func(){func() { - held, err := h.Turns.All() - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] the merge turns written before cannot be read, so no merge is handed yet: %v; asked again in %s", err, askAgainAfter) - h.again("the merge turns written before, read") - return - } - h.turnsHeld, h.turnsLoaded = held, true - h.Poke() - }} - } - want := map[string]Turn{} - if h.checkGates() { - want = h.wantedTurns() // while the check gates no merge, no merge waits for one, and no turn holds a check - } - var out []func() - repos := make([]string, 0, len(want)+len(h.turnsHeld)) - for repo := range want { - repos = append(repos, repo) - } - for repo := range h.turnsHeld { - if _, ok := want[repo]; !ok { - repos = append(repos, repo) - } - } - sort.Strings(repos) - for _, repo := range repos { - t, wanted := want[repo] - held, had := h.turnsHeld[repo] - switch { - case wanted && had && sameTurnValue(held, t): - case wanted: - out = append(out, func() { - err := h.Turns.Put(t) - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] the merge turn on %s cannot be written: %v; asked again in %s", repo, err, askAgainAfter) - h.again("a merge turn, written") - return - } - h.turnsHeld[repo] = t - h.Poke() - }) - default: - out = append(out, func() { - err := h.Turns.Delete(repo) - h.mu.Lock() - defer h.mu.Unlock() - if err != nil { - h.Logf("[mesh-delivery] the merge turn on %s cannot be deleted: %v; asked again in %s", repo, err, askAgainAfter) - h.again("a merge turn, deleted") - return - } - delete(h.turnsHeld, repo) - h.Logf("[mesh-delivery] the merge turn on %s is deleted: no approved merge waits on it, so the checker may start a check there", repo) - h.Poke() - }) - } - } - return out -} - -// sameTurnValue says whether two turns hold the same value, the version aside. -func sameTurnValue(a, b Turn) bool { - a.V, b.V = 0, 0 - return SameTurn(a, b) -} - -// turnLines are the stalled lines of approved merges held past turnBound by another delivery's check (ADR 0548 §4). Under the -// lock. -func (h *Holder) turnLines(now time.Time) []StalledLine { - var out []StalledLine - for _, d := range h.deliveries { - r := d.Ready - if r == nil || r.HeldSince == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() || now.Sub(*r.HeldSince) < turnBound { - continue - } - words := r.Waits - if words == "" { - words = "approved; waits for the check " + r.HeldFor - } - out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(*r.HeldSince).Round(time.Second).String(), - Bound: turnBound.String(), - H2: "none: the check it waits for ends, or the operator hands the merge now with delivery_hand_now — the operator's", - Says: fmt.Sprintf("%s#%d at %s is %s, held since %s; the step that check is at is read with the checker's checker_status", - d.Repository, d.Number, shortOf(r.Head, 12), words, r.HeldSince.UTC().Format(time.RFC3339))}) - } - return out -} - -// TurnLine is one repository's turn as delivery_turns reads it: the turn as the gate holds it and, for each delivery waiting, -// the check it waits for in words. -type TurnLine struct { - Repository string `json:"repository"` - Turn Turn `json:"turn"` - Waits map[string]any `json:"waits,omitempty"` -} - -// TurnsRead is the tool delivery_turns: every turn the gate holds, with the check each waiting delivery waits for, since when, -// and whether the merge was handed now on the operator's word. -func (h *Holder) TurnsRead() map[string]any { - h.mu.Lock() - defer h.mu.Unlock() - want := map[string]Turn{} - if h.checkGates() { - want = h.wantedTurns() // as turnWrites: while the check gates no merge, no turn is written by design - } - repos := make([]string, 0, len(want)) - for repo := range want { - repos = append(repos, repo) - } - sort.Strings(repos) - lines := []TurnLine{} - for _, repo := range repos { - l := TurnLine{Repository: repo, Turn: want[repo], Waits: map[string]any{}} - for _, w := range want[repo].Waiting { - says := "nothing: no check of other heads runs on " + repo - if w.WaitsFor != "" { - says = "the check " + w.WaitsFor - if !w.WaitsSince.IsZero() { - says += ", running since " + w.WaitsSince.Format(time.RFC3339) - } - } - l.Waits[w.Group] = says - } - lines = append(lines, l) - } - out := map[string]any{"turns": lines, "check-gates-merge": h.checkGates(), "bound": turnBound.String()} - if !h.checkGates() { - out["why-none"] = "the check gates no merge (check-gates-merge false): no merge waits for a check, so no turn is written" - } - if h.Turns != nil { - out["written"] = h.turnsLoaded - var unwritten []string - for _, repo := range repos { - if held, ok := h.turnsHeld[repo]; !ok || !sameTurnValue(held, want[repo]) { - unwritten = append(unwritten, repo) - } - } - for repo := range h.turnsHeld { - if _, ok := want[repo]; !ok { - unwritten = append(unwritten, repo+" (to delete)") - } - } - sort.Strings(unwritten) - if len(unwritten) > 0 { - out["not-yet-written"] = unwritten - } - } - return out -} - -// HandNow is the tool delivery_hand_now (ADR 0548 §4): on the operator's word an approved merge held for another delivery's -// check is handed at once; that check runs on and is carried to the new trunk after the merge. Refused for a delivery that is -// not an approved merge waiting, naming why. -func (h *Holder) HandNow(id, why string) (string, error) { - h.mu.Lock() - defer h.mu.Unlock() - d := h.deliveries[id] - if d == nil { - return "", fmt.Errorf("no delivery %q", id) - } - if strings.TrimSpace(why) == "" { - return "", fmt.Errorf("say why: the hand is the operator's word, kept on the delivery") - } - am, ok := h.mergeOfDelivery(d) - if !ok { - return "", fmt.Errorf("%s is not an approved merge waiting: nothing to hand (its state is %s)", id, d.State) - } - if am.run.HeldSince == nil { - return "", fmt.Errorf("%s waits for no check of another delivery: %s", id, firstOf(am.run.Waits, "it is handed at its turn")) - } - am.run.HandNow = why - h.keep(am.owner) - h.mergeAgainNow(d) - h.Logf("[mesh-delivery] %s is handed now on the operator's word, past the check %s it waited for, which is carried after the merge: %s", - id, am.run.HeldFor, why) - return fmt.Sprintf("%s is handed to the merge at the next look; the check %s it waited for runs on and is carried to the new trunk", id, am.run.HeldFor), nil -} diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values.go index 80132df1d..20b00411e 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "reflect" - "time" mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" @@ -137,63 +136,3 @@ func SameAsk(a, b Ask) bool { a.Ask, b.Ask = nil, nil return reflect.DeepEqual(a, b) && reflect.DeepEqual(x, y) } - -// Turn is the state turns' value: the approved deliveries waiting to merge in one repository, in the order of the Approve. -type Turn struct { - V int - Repository string - Waiting []TurnWait -} - -// TurnWait is one approved delivery in a turn; WaitsFor, WaitsSince and Step say the check of another delivery it waits on. -type TurnWait struct { - Group string - Heads string - Ask string - Approved time.Time - Since time.Time - WaitsFor string - WaitsSince time.Time - Step string -} - -func (t Turn) stored() seat.TurnsByRepositoryValue { - out := seat.TurnsByRepositoryValue{Repository: t.Repository, V: int64(t.V), Waiting: []seat.TurnsByRepositoryValueWaitingItem{}} - for _, w := range t.Waiting { - item := seat.TurnsByRepositoryValueWaitingItem{Approved: w.Approved, Ask: w.Ask, Group: w.Group, Heads: w.Heads, - Since: w.Since, Step: w.Step, WaitsFor: w.WaitsFor} - if !w.WaitsSince.IsZero() { - since := w.WaitsSince - item.WaitsSince = &since - } - out.Waiting = append(out.Waiting, item) - } - return out -} - -func turnFromSeat(s seat.TurnsByRepositoryValue) Turn { - out := Turn{V: int(s.V), Repository: s.Repository} - for _, w := range s.Waiting { - item := TurnWait{Group: w.Group, Heads: w.Heads, Ask: w.Ask, Approved: w.Approved, Since: w.Since, WaitsFor: w.WaitsFor, Step: w.Step} - if w.WaitsSince != nil { - item.WaitsSince = *w.WaitsSince - } - out.Waiting = append(out.Waiting, item) - } - return out -} - -// EncodeTurn is t as the value of a turns key. -func EncodeTurn(t Turn) ([]byte, error) { return mdc.EncodeTurn(t.stored()) } - -// DecodeTurn reads the value of a turns key. -func DecodeTurn(data []byte) (Turn, error) { - s, err := mdc.DecodeTurn(data) - if err != nil { - return Turn{}, err - } - return turnFromSeat(s), nil -} - -// SameTurn says whether two turns are the same value. -func SameTurn(a, b Turn) bool { return mdc.SameTurn(a.stored(), b.stored()) } diff --git a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values_test.go b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values_test.go index abb4253d7..3187afaf9 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values_test.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/values_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "reflect" "testing" - "time" mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" ) @@ -12,8 +11,6 @@ import ( // What go/delivery wrote into the seat's states, key order and all, must decode as it did and encode to the same // content (ADR 0634 §2): the gate's working values are converted to the seat's types at the edge. func TestStoredValuesOfTheEarlierLayoutDecodeUnchanged(t *testing.T) { - approved := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) - since := time.Date(2026, 11, 5, 10, 5, 0, 0, time.UTC) head := mdc.Head{Repository: "novox/app", Number: 7, Head: "0123456789abcdef0123456789abcdef01234567"} rows := []struct { name string @@ -32,13 +29,6 @@ func TestStoredValuesOfTheEarlierLayoutDecodeUnchanged(t *testing.T) { {"ask keeps the router's ask as bytes", `{"v":1,"group":"feat/x","heads":[{"repository":"novox/app","number":7,"head":"` + head.Head + `"}],"ask":{"id":"a","options":["Approve"]},"asking":2}`, func(s string) (any, error) { return DecodeAsk([]byte(s)) }, func(v any) ([]byte, error) { return EncodeAsk(v.(Ask)) }, Ask{V: 1, Group: "feat/x", Heads: []mdc.Head{head}, Ask: json.RawMessage(`{"id":"a","options":["Approve"]}`), Asking: 2}}, - {"turn without and with a check waited for", `{"v":1,"repository":"novox/app","waiting":[` + - `{"group":"feat/x","heads":"0123456789ab","ask":"merge.x","approved":"2026-11-05T10:00:00Z","since":"2026-11-05T10:00:00Z"},` + - `{"group":"feat/y","heads":"ba9876543210","approved":"2026-11-05T10:00:00Z","since":"2026-11-05T10:00:00Z","waits-for":"feat/x.0123456789ab","waits-since":"2026-11-05T10:05:00Z","step":"unit"}]}`, - func(s string) (any, error) { return DecodeTurn([]byte(s)) }, func(v any) ([]byte, error) { return EncodeTurn(v.(Turn)) }, - Turn{V: 1, Repository: "novox/app", Waiting: []TurnWait{ - {Group: "feat/x", Heads: "0123456789ab", Ask: "merge.x", Approved: approved, Since: approved}, - {Group: "feat/y", Heads: "ba9876543210", Approved: approved, Since: approved, WaitsFor: "feat/x.0123456789ab", WaitsSince: since, Step: "unit"}}}}, {"source", `{"v":1,"group":"feat/x","heads":{"novox/app":"c0ffee"}}`, func(s string) (any, error) { return mdc.DecodeSource([]byte(s)) }, func(v any) ([]byte, error) { return mdc.EncodeSource(v.(Source)) }, Source{V: 1, Group: "feat/x", Heads: map[string]string{"novox/app": "c0ffee"}}}, @@ -74,8 +64,5 @@ func sameValue(a, b any) bool { if x, ok := a.(Ask); ok { return SameAsk(x, b.(Ask)) } - if x, ok := a.(Turn); ok { - return SameTurn(x, b.(Turn)) && reflect.DeepEqual(len(x.Waiting), len(b.(Turn).Waiting)) && x.Waiting[len(x.Waiting)-1].WaitsSince.Equal(b.(Turn).Waiting[len(x.Waiting)-1].WaitsSince) - } return reflect.DeepEqual(a, b) } 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 ff854fe20..18378d182 100644 --- a/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go +++ b/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/verbs.go @@ -84,7 +84,7 @@ func (h *Holder) Show(id string) (any, error) { h.mu.Lock() 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()} + out := map[string]any{"delivery": d, "waits": h.waitsFor(d)} if m := h.machines(d); m != "" { out["machines"] = m } @@ -97,7 +97,7 @@ func (h *Holder) Show(id string) (any, error) { if g := h.groups[id]; g != nil { members := h.membersOf(g) state, why := GroupState(g, members) - return map[string]any{"group": g, "state": state, "why": why, "members": members, "check-gates-merge": h.checkGates()}, nil + return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil } return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id) } @@ -246,7 +246,6 @@ func (h *Holder) Stalled() []StalledLine { } out = append(out, h.withChecks(h.uncheckedLines(now))...) out = append(out, h.readyLines(now)...) - out = append(out, h.turnLines(now)...) // an approved merge held past its bound by another delivery's check (ADR 0548 §4) out = append(out, h.groupLines(now)...) out = append(out, h.owedLines(now)...) // an act owed the forge with no outcome past its bound (W11.17) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) @@ -316,7 +315,7 @@ func (h *Holder) Status(listening string) map[string]any { byState[string(s)] = n } out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty), - "events": listening, "started": h.started.Format(time.RFC3339), "check-gates-merge": h.checkGates()} + "events": listening, "started": h.started.Format(time.RFC3339)} // The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again, // or given up, and when the last was tried. A count since the start, said with that start: two readings in // one run give a window's tries. diff --git a/modules/mesh-delivery-gate/files/settings.json b/modules/mesh-delivery-gate/files/settings.json index e164ea53c..8c179a898 100644 --- a/modules/mesh-delivery-gate/files/settings.json +++ b/modules/mesh-delivery-gate/files/settings.json @@ -1,6 +1,5 @@ { "refusal-stall-floor-minutes": 10, "author-new-head-minutes": 120, - "check-gates-merge": true, "record-retention": 90 } diff --git a/modules/mesh-delivery-gate/module.json b/modules/mesh-delivery-gate/module.json index cf20565ca..e462c942a 100644 --- a/modules/mesh-delivery-gate/module.json +++ b/modules/mesh-delivery-gate/module.json @@ -316,11 +316,7 @@ "name": "source", "history": 64 }, - "conditions", - { - "name": "turns", - "history": 5 - } + "conditions" ], "reads": [ "mesh-delivery-teller.answers", @@ -328,10 +324,8 @@ "mesh-delivery-view.reach" ], "tools": [ - "delivery_hand_now", "delivery_merge_again", "delivery_status", - "delivery_turns", "gate_consumer", "gate_record", "gate_record_end", @@ -354,12 +348,6 @@ "trusted": false, "default": 120 }, - "check-gates-merge": { - "type": "boolean", - "description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a ready pull request (no review line is waited for, ADR 0659) has its Approve is asked once the check at its heads (a group: of all its heads together) has a verdict, a pass or a failure said in the ask, or after 60 minutes with none, said so (the operator, 2026-10-27 23:54: \"if you don't want me to approve it, don't send the approval request\"); an approved merge waits for no verdict; no merge is refused for a check judged on an older trunk; a later verdict is shown on the pull request and the delivery, and asks a pending Approve again in its words; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.", - "trusted": false, - "default": true - }, "record-retention": { "type": "number", "description": "days a delivery record is kept from when it opened (design 47 *The delivery record*): ninety covers every trace asked of a past delivery so far; when a record is removed at its retention, the gate says freed for each graph revision it pinned. Nothing else is removed to make room.", @@ -385,7 +373,6 @@ "takes-config": [ "refusal-stall-floor-minutes", "author-new-head-minutes", - "check-gates-merge", "record-retention" ], "merge": "json",