Merge pull request 'mesh-delivery: end a merge that moves nothing or that a later merge delivered, and lift a group hold its check answered (hq issues 319, 316)' (#150) from fix/319-316-covered-and-lifted into main

This commit was merged in pull request #150.
This commit is contained in:
2026-10-09 07:30:15 +00:00
15 changed files with 857 additions and 23 deletions
+17
View File
@@ -81,3 +81,20 @@ export function mergeableUnder(branch: string, rule: BranchProtection | null | u
blocking.map((b) => `${b.context} (${b.state})`).join(", ");
return { branch, protected: !!rule, required, blocking, mergeable: blocking.length === 0, says };
}
/** A commit, by its sha or the start of one: what `gitea_contains` takes, so a branch name or an expression
* never reaches the compare's path. */
export function isCommit(ref: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(ref);
}
/** Whether a commit is contained in another — an ancestor of it, or it (novox/hq ADR 0270) — from the forge's
* compare of `in...commit`: the commits on `commit` that `in` lacks. None is contained. An answer that does not
* say how many is no answer. */
export function containedFrom(compare: unknown): { contains: boolean; ahead: number } {
const total = (compare as Record<string, unknown> | null)?.total_commits;
if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
throw new Error("the forge's compare said no total_commits");
}
return { contains: total === 0, ahead: total };
}
+6
View File
@@ -355,6 +355,12 @@ export class GiteaClient {
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
}
/** The forge's compare of two commits, `base...head`: the commits on head that base lacks, and how many
* (novox/hq ADR 0270). Reads only. */
async compare(owner: string, repo: string, base: string, head: string): Promise<unknown> {
return this.request<unknown>(`/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
+17
View File
@@ -46,3 +46,20 @@ test("no rule requires nothing; a rule that cannot be read says nothing, never y
assert.equal(unread.mergeable, null);
assert.match(unread.says, /could not be read/);
});
// novox/hq ADR 0270: a held delivery is covered only when a later delivered merge contains it.
test("a commit is contained in another when the compare finds nothing on it the other lacks", async () => {
const { containedFrom, isCommit } = await import("../checks.ts");
for (const [compare, contains, ahead] of [
[{ total_commits: 0, commits: [] }, true, 0],
[{ total_commits: 3, commits: [{}, {}, {}] }, false, 3],
] as const) {
assert.deepEqual(containedFrom(compare), { contains, ahead });
}
for (const bad of [{}, { total_commits: "0" }, { total_commits: -1 }, null]) {
assert.throws(() => containedFrom(bad), /total_commits/);
}
assert.equal(isCommit("6aabe8e"), true);
assert.equal(isCommit("90516fc0a1b2c3d4e5f60718293a4b5c6d7e8f90"), true);
for (const bad of ["main", "6aabe8e...90516fc", "../x", "6aabe8", ""]) assert.equal(isCommit(bad), false, bad);
});
+1 -1
View File
@@ -411,7 +411,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_branches",
"gitea_delete_branch",
"gitea_branch_protection_get", "gitea_branch_protection_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_status",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_contains", "gitea_commit_status",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
+19 -1
View File
@@ -12,7 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
import { mergeableUnder, newestByContext } from "../checks.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -492,6 +492,24 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
merge: mergeableUnder(base, rule, statuses, unreadable) };
},
},
{
name: "gitea_contains",
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
commit: { type: "string", description: "the commit asked about" },
in: { type: "string", description: "the commit it may be contained in" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const commit = String(args.commit ?? "").trim(), within = String(args.in ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!isCommit(commit) || !isCommit(within)) throw new Error("commit and in are commits: a sha, or the start of one");
const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit));
return { commit, in: within, contains, ahead };
},
},
{
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them.",
@@ -0,0 +1,476 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
)
// novox/hq issues 319 and 316: a held delivery whose change already runs, or that builds nothing, is not a
// question for a person. A merge that moves nothing is delivered at once; a merge whose every module a later
// merge on the same trunk delivered is covered — never stopped, never an error on its pull request; and a
// group member held for a composed check that had not answered goes on once it passes.
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// mergedAt is the forge's `pull.merged` at a moment.
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
p.MergeCommit = merge
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
// verdictMoving is a passing verdict whose plan moves these modules — or builds nothing, given none.
func verdictMoving(repo string, number int, sha string, modules ...string) CheckedEvent {
c := verdict(repo, number, sha, "pass")
c.Plan = planMoving(repo, modules...)
return c
}
func planMoving(repo string, modules ...string) *DeliveryPlan {
if len(modules) == 0 {
return &DeliveryPlan{Repository: repo, Base: "main", Summary: "builds nothing: the change touches no module of the mesh's graph"}
}
return &DeliveryPlan{Repository: repo, Base: "main", Moved: modules, Summary: "builds " + strings.Join(modules, ", ") + " → anchor"}
}
// deliverLater is a later merge into a trunk, walked: its walk opened for its merge commit, let go, and left
// in the state given, every module of it sent.
func deliverLater(w *world, repo string, number int, base, headSHA, merge string, at time.Time, state string,
modules ...string) string {
w.t.Helper()
p := pr(repo, number, headSHA, fmt.Sprintf("feat/later-%d", number))
p.Base = base
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(repo, number, headSHA, modules...))
w.h.PullMerged(mergedAt(p, merge, at))
walk := aWalk("plan-"+merge[:6], repo, merge, true)
walk.Branch = base
walk.Modules = map[string]*WalkModule{}
for _, m := range modules {
walk.Modules[m] = &WalkModule{}
}
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
walk = w.ctl.walk(walk.ID)
sent := at.Add(5 * time.Minute)
for _, m := range modules {
walk.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
walk.State = state
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
return IDOf(repo, headSHA)
}
func statusOn(w *world, repo, commit string) string {
w.forge.mu.Lock()
defer w.forge.mu.Unlock()
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
}
// **A merge that moves nothing is delivered at once** (issue 319): there is nothing for a walk to move and
// nothing for a person to decide — whether its check passed, failed or never answered, and whether its plan
// builds nothing at all or only a new module the controller builds and sends nowhere (issue 300).
func TestAMergeThatMovesNothingIsDeliveredAtOnce(t *testing.T) {
for _, c := range []struct {
name string
verdict string // "" is a merge before any verdict
plan *DeliveryPlan
}{
{"builds nothing, merged before its check answered", "", planMoving("novox/hq")},
{"builds nothing, merged on a failed check", "fail", planMoving("novox/hq")},
{"builds nothing, merged on a passing check", "pass", planMoving("novox/hq")},
{"a new module, sent nowhere", "pass", &DeliveryPlan{Repository: "novox/hq", New: []string{"modules/zfs"},
Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
{"a new module, sent nowhere, merged before its check answered", "", &DeliveryPlan{Repository: "novox/hq",
New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
w.ctl.planned = c.plan
p := pr("novox/hq", 7, head, "docs/x", "02-DECISIONS/x.md")
w.h.PullUpdated(p)
if c.verdict != "" {
v := verdict("novox/hq", 7, head, c.verdict)
v.Plan = c.plan
w.h.Checked(v)
}
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
// Past the bound a merge waits for its walk: nothing holds it then either.
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
d := w.delivery(IDOf("novox/hq", head))
if d.State != Delivered {
t.Fatalf("a merge whose plan is %q is %s (%s), not delivered", c.plan.Summary, d.State, d.HeldWhy)
}
if s := statusOn(w, "novox/hq", head); !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
})
}
}
// **A held delivery whose every module a later merge on its trunk delivered is covered** (issue 319): the
// later merge commit contains it, its walk is done, and it sent each module this one moves. Anything short
// of that — a module not carried, an earlier merge, another trunk, a walk that did not finish — leaves it held
// for a person, as before.
func TestAMergeALaterDeliveredWalkCarriedIsCovered(t *testing.T) {
const ctl = "novox/mesh-controller"
for _, c := range []struct {
name string
// The later merge: its trunk, minutes after t0 it merged (this one merged at 30), its walk's end, and
// what it sent.
base string
at int
state string
modules []string
// Whether this one was held for a person before the later merge was delivered.
heldFirst bool
// What the forge says of the later merge containing this one: yes, no, or away until asked again.
forge string
want State
}{
{"carried every module, delivered after this one was held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "yes", Covered},
{"carried every module, delivered before this one was due to be held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller", "route-proxy"}, false, "yes", Covered},
{"carried one of its two modules", "main", 35, walkDone, []string{"mesh-controller"}, true, "yes", Held},
{"merged before this one", "main", 25, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"merged into another branch", "release", 35, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"its walk failed", "main", 35, walkFailed, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"the forge says the later merge does not contain it", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "no", Held},
{"the forge could not say at first, then said it does", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "away", Covered},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
pair := merge + " in nnnnnnnnnnnn8888"
switch c.forge {
case "yes", "away":
w.forge.contains[pair] = true
case "no":
w.forge.contains[pair] = false
}
w.forge.containsDown = c.forge == "away"
p := pr(ctl, 1, head, "feat/one", "cmd/a.go")
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(ctl, 1, head, "mesh-controller", "build-agent"))
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
if c.heldFirst {
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("a merge no walk opened for is %s before the later one", s)
}
}
later := deliverLater(w, ctl, 2, c.base, "bbbbbbbbbbbb2222", "nnnnnnnnnnnn8888", t0.Add(time.Duration(c.at)*time.Minute),
c.state, c.modules...)
if c.state == walkDone && w.state(later) != Delivered {
t.Fatalf("the later merge is %s", w.state(later))
}
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if c.forge == "away" {
// A forge that cannot say is "not yet covered": held, and asked again a minute on.
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("with the forge away it is %s", s)
}
w.forge.mu.Lock()
w.forge.containsDown = false
w.forge.mu.Unlock()
w.later(2 * time.Minute)
w.settleAll()
}
d := w.delivery(IDOf(ctl, head))
if d.State != c.want {
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
}
s := statusOn(w, ctl, head)
if c.want == Covered {
if !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") ||
!strings.Contains(s, "nnnnnnnn") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
last := d.Transitions[len(d.Transitions)-1]
if !strings.Contains(last.Why, IDOf(ctl, "bbbbbbbbbbbb2222")) {
t.Fatalf("its transition does not name what covered it: %q", last.Why)
}
}
if strings.HasPrefix(s, "error") {
t.Fatalf("its pull request's mesh/delivery is an error: %q", s)
}
// Asked once for the pair, however many ticks read it — once more only after the forge was away.
asked := 0
for _, a := range w.forge.containsAsks {
if a == pair {
asked++
}
}
candidate := c.base == "main" && c.at > 30 && c.state == walkDone && len(c.modules) >= 2
switch {
case !candidate && asked != 0:
t.Fatalf("the forge was asked whether %s for a merge order and walk that cannot cover it", pair)
case candidate && c.forge != "away" && asked != 1:
t.Fatalf("the forge was asked %d time(s) whether %s, want once", asked, pair)
case candidate && c.forge == "away" && asked < 2:
t.Fatalf("the forge, away, was asked %d time(s) whether %s: never again", asked, pair)
}
})
}
}
// A covered member of a group counts as delivered: the member after it is let go, not stopped.
func TestACoveredGroupMemberLetsTheNextOneGo(t *testing.T) {
w := newWorld(t)
const ctl, lab = "novox/mesh-controller", "novox/mesh-lab"
const ctlHead, labHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctlID, labID := IDOf(ctl, ctlHead), IDOf(lab, labHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctlID, labID}} }
pc, pl := pr(ctl, 1, ctlHead, "feat/g", "cmd/a.go"), pr(lab, 2, labHead, "feat/g", "replays/a.go")
w.h.PullUpdated(pc)
w.h.PullUpdated(pl)
w.h.Checked(verdictMoving(ctl, 1, ctlHead, "mesh-controller"))
w.h.Checked(verdictMoving(lab, 2, labHead, "mesh-lab"))
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/g", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(pc, "m1m1m1m1m1m1", t0.Add(30*time.Minute)))
w.h.PullMerged(mergedAt(pl, "m2m2m2m2m2m2", t0.Add(31*time.Minute)))
w.h.WalkMoved(w.ctl.put(aWalk("plan-lab", lab, "m2m2m2m2m2m2", true)))
w.settleAll()
w.forge.contains["m1m1m1m1m1m1 in m3m3m3m3m3m3"] = true
deliverLater(w, ctl, 3, "main", "e3e3e3e3e3e3e3e3", "m3m3m3m3m3m3", t0.Add(35*time.Minute), walkDone, "mesh-controller")
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if w.state(ctlID) != Covered {
t.Fatalf("the controller's member is %s", w.state(ctlID))
}
if w.state(labID) != Delivering || !containsString(w.ctl.delivers, "plan-lab") {
t.Fatalf("the member after it is %s, let go %v", w.state(labID), w.ctl.delivers)
}
lw := w.ctl.walk("plan-lab")
lw.State = walkDone
w.h.WalkMoved(w.ctl.put(lw))
w.settleAll()
if state, why := GroupState(w.h.groups["feat/g"], w.h.membersOf(w.h.groups["feat/g"])); state != "delivered" {
t.Fatalf("the group is %s: %s", state, why)
}
}
// **On rollout, the deliveries already held close by themselves** (issue 319's held ones), and a restart
// after that moves nothing again: what was read back is where it stays.
func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
coverer := func(repo, module string) *Delivery {
return &Delivery{ID: IDOf(repo, "c0c0c0c0c0c0c0c0"), Repository: repo, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(repo, module), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: []Step{{Machine: "anchor", Module: module, State: StepPassed}, {Machine: "the rest running it", Module: module, State: StepSent}}}
}
held := func(repo string, plan *DeliveryPlan, why string) *Delivery {
return &Delivery{ID: IDOf(repo, head), Repository: repo, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: plan, Transitions: []Transition{{At: merged1, From: Ready, To: Held, Event: EvHold, Why: why}}}
}
const unwalkedWhy = "merged, and the controller opened no walk for it within 10m0s — nothing it holds follows that branch, " +
"or the merge was not heard: a person decides"
withCoverer := func(d *Delivery, change func(c *Delivery)) []*Delivery {
c := coverer(d.Repository, d.Plan.Moved[0])
change(c)
return []*Delivery{d, c}
}
const uncheckedWhy = "merged without a passing check: only a person decides that it goes on"
for _, c := range []struct {
name string
kept []*Delivery
group *Group
want State
}{
{"merged without a passing check, builds nothing",
[]*Delivery{held("novox/hq", planMoving("novox/hq"), "merged without a passing check: only a person decides that it goes on")},
nil, Delivered},
{"a new module sent nowhere, no walk opened",
[]*Delivery{held(cat, &DeliveryPlan{New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere"}, unwalkedWhy)},
nil, Delivered},
{"no walk opened, a later delivered walk carried its module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "mesh-controller")}, nil, Covered},
{"merged without a passing check, a later delivered walk whose own check passed carried its module",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(*Delivery) {}), nil, Covered},
{"merged without a passing check, the later delivery's own check did not pass",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(c *Delivery) {
c.Check = &Verdict{Gate: "fail"}
}), nil, Held},
{"no walk opened, the later delivery's walk was of another commit",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Commit = "e0e0e0e0e0e0e0e0"
}), nil, Held},
{"no walk opened, the later walk sent its module to its first machine only",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Steps = c.Steps[:1]
}), nil, Held},
{"no walk opened, the later walk's branch is another",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Branch = "release"
}), nil, Held},
{"no walk opened, a later delivered walk carried another module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "route-proxy")}, nil, Held},
{"held for its group, its own walk superseded, a later delivered walk carried its module",
func() []*Delivery {
d := held(cat, planMoving(cat, "systemd"), "its group feat/j's composed check did not pass for the heads that "+
"merged; a person decides that it goes on")
d.Group = "feat/j"
d.Walk = &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"}
other := &Delivery{ID: IDOf("novox/mesh-host", "abababababababab"), Repository: "novox/mesh-host",
Commit: "abababababababab", Number: 3, Base: "main", Group: "feat/j", State: Delivered,
MergedAs: "acacacacacacacac", MergedAt: merged1}
return []*Delivery{d, other, coverer(cat, "systemd")}
}(),
&Group{ID: "feat/j", Members: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")}, Closed: true,
Order: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")},
Check: &GroupCheck{Asked: "check-1", Verdict: "pass", Commits: map[string]string{IDOf(cat, head): head,
IDOf("novox/mesh-host", "abababababababab"): "abababababababab"}}},
Covered},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
for _, d := range c.kept {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
if c.group != nil {
if err := store.PutGroup(c.group); err != nil {
t.Fatal(err)
}
}
now := merged2.Add(30 * time.Hour)
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, now)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
id := c.kept[0].ID
d := w.delivery(id)
if d.State != c.want {
t.Fatalf("read back held, it is %s, want %s", d.State, c.want)
}
if s := statusOn(w, d.Repository, d.Commit); c.want != Held && !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
// A restart over what it kept: nothing moves again.
again := newWorldOver(t, store, newFakeController(), forge, now.Add(time.Hour))
if err := again.h.Reconcile(); err != nil {
t.Fatal(err)
}
again.settleAll()
if d2 := again.delivery(id); d2.State != d.State || len(d2.Transitions) != len(d.Transitions) {
t.Fatalf("a restart moved it again: %s with %d transitions, was %s with %d", d2.State, len(d2.Transitions),
d.State, len(d.Transitions))
}
})
}
}
// **A group member is not held for a composed check that has not answered** (issue 316): it waits published,
// and goes on once the check passes for the heads that merged.
func TestAGroupMemberWaitsForItsComposedCheckToAnswer(t *testing.T) {
w := newWorld(t)
const a, b = "novox/a", "novox/b"
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
aID := IDOf(a, aHead)
pa, pb := pr(a, 1, aHead, "feat/k", "x"), pr(b, 2, bHead, "feat/k", "y")
w.h.PullUpdated(pa)
w.h.PullUpdated(pb)
w.h.Checked(verdictMoving(a, 1, aHead, "a"))
w.h.Checked(verdictMoving(b, 2, bHead, "b"))
w.settleAll()
if len(w.ctl.checks) != 1 {
t.Fatalf("the composed check was asked %v", w.ctl.checks)
}
w.h.PullMerged(mergedAt(pa, "m1m1m1m1m1m1", t0))
w.h.WalkMoved(w.ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true)))
w.settleAll()
if s := w.state(aID); s != Published {
t.Fatalf("merged while its group's composed check had not answered, it is %s (%s)", s, w.delivery(aID).HeldWhy)
}
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if s := w.state(aID); s != Delivering || !containsString(w.ctl.delivers, "plan-a") {
t.Fatalf("its composed check passed, and it is %s, let go %v", s, w.ctl.delivers)
}
}
// **A member already held for its group's composed check goes on when that check passes for the heads that
// merged** (issue 316); a check that failed, or passed for other heads, keeps it held for a person.
func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
const a, b = "novox/a", "novox/b"
const aHead, bHead = "a1a1a1a1a1a1a1a1", "b2b2b2b2b2b2b2b2"
aID, bID := IDOf(a, aHead), IDOf(b, bHead)
for _, c := range []struct {
name string
verdict string
commits map[string]string
want State
}{
{"passed for the heads that merged", "pass", map[string]string{aID: aHead, bID: bHead}, Delivering},
{"warned for the heads that merged", "warning", map[string]string{aID: aHead, bID: bHead}, Delivering},
{"failed", "fail", map[string]string{aID: aHead, bID: bHead}, Held},
{"passed for other heads", "pass", map[string]string{aID: "a9a9a9a9a9a9a9a9", bID: bHead}, Held},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
why := "its group feat/k's composed check did not pass for the heads that merged; a person decides that it goes on"
da := &Delivery{ID: aID, Repository: a, Commit: aHead, Number: 1, Base: "main", Branch: "feat/k", Group: "feat/k",
State: Held, Since: t0, Created: t0.Add(-time.Hour), MergedAs: "m1m1m1m1m1m1", MergedAt: t0, HeldWhy: why,
Plan: planMoving(a, "a"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-a", Commit: "m1m1m1m1m1m1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}}
db := &Delivery{ID: bID, Repository: b, Commit: bHead, Number: 2, Base: "main", Branch: "feat/k", Group: "feat/k",
State: Ready, Since: t0, Created: t0.Add(-time.Hour), Plan: planMoving(b, "b"), Check: &Verdict{Gate: "pass"}}
g := &Group{ID: "feat/k", Members: []string{aID, bID}, Order: []string{aID, bID}, Closed: true,
OrderOf: map[string]string{aID: aHead, bID: bHead},
Check: &GroupCheck{Asked: "check-1", At: t0, Commits: c.commits}}
for _, d := range []*Delivery{da, db} {
_ = store.PutDelivery(d)
}
_ = store.PutGroup(g)
ctl := newFakeController()
ctl.put(aWalk("plan-a", a, "m1m1m1m1m1m1", true))
w := newWorldOver(t, store, ctl, newFakeForge(), t0.Add(time.Minute))
w.settleAll()
if s := w.state(aID); s != Held {
t.Fatalf("before its composed verdict it is %s", s)
}
w.h.Checked(CheckedEvent{Group: "feat/k", ID: "check-1", Verdict: c.verdict, Summary: "composed: " + c.verdict})
w.settleAll()
if s := w.state(aID); s != c.want {
t.Fatalf("its composed check %s, and it is %s (%s), want %s", c.name, s, w.delivery(aID).HeldWhy, c.want)
}
if c.want == Delivering && !containsString(ctl.delivers, "plan-a") {
t.Fatalf("lifted, its walk was not let go: %v", ctl.delivers)
}
})
}
}
@@ -107,6 +107,8 @@ func statusOf(d *Delivery) (string, string) {
return "error", clip("stopped: "+why, 140)
case Retired:
return "success", "history: merged before this owner delivered anything, never walked"
case Covered:
return "success", clip("covered: a later merge that contains it was delivered: "+d.CoveredBy, 140)
}
return "pending", string(d.State)
}
@@ -95,6 +95,8 @@ type fakeController struct {
down bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
planned *DeliveryPlan
}
func newFakeController() *fakeController {
@@ -108,6 +110,10 @@ func (c *fakeController) Plan(repository, base, head string, paths, dirs, remove
if c.down {
return nil, errors.New("down")
}
if c.planned != nil {
p := *c.planned
return &p, nil
}
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
}
func (c *fakeController) Order(ms []Member) (Order, error) {
@@ -229,11 +235,15 @@ type fakeForge struct {
required []string
protectionDown bool
asked []string
// contains answers gitea_contains by "<commit> in <in>"; a pair it does not hold is a forge that cannot say.
contains map[string]bool
containsDown bool
containsAsks []string
}
func newFakeForge() *fakeForge {
return &fakeForge{notes: map[string][]string{}, views: map[string]string{}, statuses: map[string]string{},
pulls: map[string]ForgePull{}}
pulls: map[string]ForgePull{}, contains: map[string]bool{}}
}
func (f *fakeForge) Note(owner, repo, commit, line string) error {
@@ -341,6 +351,21 @@ func (f *fakeForge) Statuses(owner, repo string, number int, commit, base string
return out, nil
}
// Contains answers what the fake was told of two commits.
func (f *fakeForge) Contains(owner, repo, commit, in string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.containsAsks = append(f.containsAsks, commit+" in "+in)
if f.containsDown || f.down {
return false, errors.New("the forge is away")
}
yes, said := f.contains[commit+" in "+in]
if !said {
return false, fmt.Errorf("the forge has no commit %s or %s", commit, in)
}
return yes, nil
}
func (f *fakeForge) notesOn(commit string) []string {
f.mu.Lock()
defer f.mu.Unlock()
@@ -160,7 +160,7 @@ func (d *Delivery) owe(e Effect) {
func afterTheTrunk(s State) bool {
return s == Published || s == Delivering || s == Held || s == Delivered || s == Failed || s == Stopped ||
s == Superseded || s == Retired
s == Superseded || s == Retired || s == Covered
}
func orNothing(s State) string {
@@ -755,10 +755,32 @@ func (h *Holder) plan() []func() {
}
}
// Held, or merged with no walk opened for it: ended by what is already true of it, never asked of a person
// (novox/hq issue 319). A plan that moves nothing is delivered; one whose every module a later merge into the
// same trunk delivered is covered. Read again each tick, so a delivery read back after a restart, or held
// before this was known, ends by itself.
for _, d := range h.deliveries {
if d.State != Held && !h.unwalkedPast(d, now) {
continue
}
if d.CoveredBy == "" {
var ask []string
d.CoveredBy, ask = h.coverOf(d)
for _, in := range ask {
if a := h.askContains(d, in, now); a != nil {
asks = append(asks, a)
}
}
}
if ts := settle(d, Facts{Now: now}); len(ts) > 0 {
h.moved(d, ts)
h.closeGroupOf(d)
}
}
// Merged, and no walk opened for it after the walks were read again: never silent — held, saying so.
for _, d := range h.deliveries {
if d.State == Ready && d.MergedAs != "" && d.Walk == nil && !d.MovesNothing() && d.HeldWhy == "" &&
now.Sub(d.MergedAt) > unwalked && h.reconciled.After(d.MergedAt.Add(unwalked)) {
if h.unwalkedPast(d, now) {
d.HeldWhy = "merged, and the controller opened no walk for it within " + unwalked.String() + " — nothing it " +
"holds follows that branch, or the merge was not heard: a person decides"
h.settleAndKeep(d)
@@ -796,6 +818,128 @@ func (h *Holder) plan() []func() {
return asks
}
// unwalkedPast is whether a merged delivery has gone without a walk past the bound one opens within, read
// again after it: what holds it for a person, unless something already ends it.
func (h *Holder) unwalkedPast(d *Delivery, now time.Time) bool {
return d.State == Ready && d.MergedAs != "" && d.Walk == nil && !d.MovesNothing() && d.HeldWhy == "" &&
now.Sub(d.MergedAt) > unwalked && h.reconciled.After(d.MergedAt.Add(unwalked))
}
// coverOf says which later merges cover a merged delivery, or nothing, and which later merge commits the forge
// must still be asked about (novox/hq ADR 0270). A module is covered by a later delivery that carries it (see
// carries) and whose merge commit the forge said contains this one's merge. Merge order is the cheap filter;
// ancestry decides. The forge is asked only once every module has a candidate, once per pair, and its answer
// is kept on the delivery: an answer not known yet is "not yet covered".
func (h *Holder) coverOf(d *Delivery) (string, []string) {
if d.MergedAs == "" || d.MergedAt.IsZero() || d.Base == "" || d.Plan == nil || len(d.Plan.Moved) == 0 {
return "", nil
}
candidates := map[string][]*Delivery{}
for _, module := range d.Plan.Moved {
for _, c := range h.deliveries {
if carries(c, d, module) {
candidates[module] = append(candidates[module], c)
}
}
if len(candidates[module]) == 0 {
return "", nil // nothing later carried it: nothing to ask
}
}
carried := map[string][]string{}
var by []*Delivery
var ask []string
for _, module := range d.Plan.Moved {
var first *Delivery
for _, c := range candidates[module] {
yes, known := d.ContainedIn[c.MergedAs]
if !known && !containsString(ask, c.MergedAs) {
ask = append(ask, c.MergedAs)
}
if yes && (first == nil || c.MergedAt.Before(first.MergedAt) || c.MergedAt.Equal(first.MergedAt) && c.ID < first.ID) {
first = c
}
}
if first == nil {
return "", ask
}
if carried[first.ID] == nil {
by = append(by, first)
}
carried[first.ID] = append(carried[first.ID], module)
}
sort.Slice(by, func(i, j int) bool { return by[i].MergedAt.Before(by[j].MergedAt) })
var parts []string
for _, c := range by {
mods := carried[c.ID]
sort.Strings(mods)
parts = append(parts, fmt.Sprintf("%s (merged as %s into %s, walk %s): %s", c.ID, shortOf(c.MergedAs, 8), d.Base,
c.Walk.ID, strings.Join(mods, ", ")))
}
return strings.Join(parts, "; "), nil
}
// carries is whether c delivered a module after d: of the same repository, merged later into the same trunk,
// delivered on its own passing check — a merge held without one is covered only by a later merge that passed
// its own — by its own walk, of its merge commit on that trunk, which sent the module to every machine running
// it. Whether c's merge commit contains d's is the forge's to say (coverOf).
func carries(c, d *Delivery, module string) bool {
if c.ID == d.ID || c.State != Delivered || !c.Check.Passes() || !strings.EqualFold(c.Repository, d.Repository) ||
!strings.EqualFold(c.Base, d.Base) || c.MergedAs == "" || sameCommit(c.MergedAs, d.MergedAs) ||
!c.MergedAt.After(d.MergedAt) || c.Walk == nil || c.Walk.State != walkDone || !sameCommit(c.Walk.Commit, c.MergedAs) ||
c.Walk.Branch != "" && !strings.EqualFold(c.Walk.Branch, d.Base) {
return false
}
everywhere := false
for _, s := range c.Steps {
if s.Module != module {
continue
}
switch {
case s.State == StepFailed || s.State == StepRolledBack:
return false
case s.State == StepSent && (s.Machine == stepEveryMachine || s.Machine == stepTheRest):
everywhere = true
}
}
return everywhere
}
// askContains is the ask of the forge whether a later merge commit contains a delivery's merge: at most once a
// minute while the forge cannot say, and never again once it said — its answer is kept on the delivery.
func (h *Holder) askContains(d *Delivery, in string, now time.Time) func() {
key := d.ID + " in " + in
if tried, was := h.askTried[key]; was && now.Sub(tried) < time.Minute {
return nil
}
h.askTried[key] = now
id, owner, repo, merged := d.ID, d.Owner(), d.Repo(), d.MergedAs
return func() {
yes, err := h.Forge.Contains(owner, repo, merged, in)
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] %s: whether %s contains its merge cannot be read yet (%v): not yet covered", id,
shortOf(in, 12), err)
return
}
delete(h.askTried, key)
d := h.deliveries[id]
if d == nil {
return
}
if d.ContainedIn == nil {
d.ContainedIn = map[string]bool{}
}
d.ContainedIn[in] = yes
h.keep(d)
}
}
// heldForGroup is whether a member is held only because its group's composed check had not passed.
func heldForGroup(d *Delivery, g *Group) bool {
return d.State == Held && strings.HasPrefix(d.HeldWhy, fmt.Sprintf("its group %s's composed check", g.ID))
}
// askCheck is the ask of a proposed delivery's own check, when nothing else will bring its verdict: no verdict,
// no check this owner asked, and proposed past the grace the controller's own check of its announcement is
// given. Nil otherwise — once asked, a late verdict is the bound's and H2's to speak for.
@@ -1031,7 +1175,7 @@ func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
for i, m := range ordered {
d := byID[m.ID]
switch {
case d.State == Delivered || d.State == Retired:
case d.State.Reached():
continue
case d.State == Failed || d.State == Stopped || d.State == Superseded:
var asks []func()
@@ -1041,7 +1185,27 @@ func (h *Holder) groupTurns(g *Group, members []*Delivery) []func() {
}
}
return asks
case d.State == Held:
// Held for a composed check that has since passed for the heads that merged: lifted, published again,
// and its turn taken in order (novox/hq issue 316). Any other hold is a person's.
if composed && heldForGroup(d, g) && walkCanGoOn(d) {
d.HeldWhy = ""
d.Lifted = fmt.Sprintf("its group %s's composed check said %s for the heads that merged: %s", g.ID,
g.Check.Verdict, g.Check.Summary)
h.settleAndKeep(d)
if d.State == Published {
if a := h.turn(d, fmt.Sprintf("its turn in group %s (%d of %d)", g.ID, i+1, len(ordered))); a != nil {
return []func(){a}
}
}
}
return nil
case d.State == Published:
// Asked and not answered is not "did not pass": it waits, published, for the answer (issue 316).
if !composed && d.HeldWhy == "" && g.Check != nil && g.Check.Verdict == "" &&
sameMembers(g.Check.Commits, commitsOf(members)) {
return nil
}
if !composed && d.HeldWhy == "" {
d.HeldWhy = fmt.Sprintf("its group %s's composed check did not pass for the heads that merged; a person "+
"decides that it goes on", g.ID)
@@ -1093,10 +1257,10 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
return "failed", "a member failed"
case count[Stopped] > 0 && g.Closed:
return "stopped", "a member was stopped"
case n > 0 && count[Delivered]+count[Retired] == n:
case n > 0 && count[Delivered]+count[Retired]+count[Covered] == n:
return "delivered", "every member is delivered"
case g.Closed:
return "delivering", fmt.Sprintf("%d of %d delivered", count[Delivered], n)
return "delivering", fmt.Sprintf("%d of %d delivered", count[Delivered]+count[Covered], n)
case len(g.Cycle) > 0:
return "rejected", cycleSaid(g)
case count[Rejected] > 0:
@@ -65,7 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses")
"gitea.gitea_commit_statuses", "gitea.gitea_contains")
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
}
@@ -61,6 +61,13 @@ type Delivery struct {
// History is why it is history, once it is (novox/hq issue 313).
FromForge bool `json:"from_forge,omitempty"`
History string `json:"history,omitempty"`
// CoveredBy says which later merges into the same trunk delivered every module it moves, once they did
// (novox/hq issue 319); Lifted why what held it for a person no longer holds (issue 316).
CoveredBy string `json:"covered_by,omitempty"`
Lifted string `json:"lifted,omitempty"`
// ContainedIn is the forge's answer, by a later merge commit, to whether that commit contains this one's
// merge (gitea_contains): asked once per pair, and kept (ADR 0270).
ContainedIn map[string]bool `json:"contained_in,omitempty"`
// Walk is the controller's walk of its trunk commit, as last read; Steps the machines it reached.
Walk *WalkSeen `json:"walk,omitempty"`
Steps []Step `json:"steps,omitempty"`
@@ -144,10 +151,11 @@ type MachinePlan struct {
Waits []string `json:"waits,omitempty"`
}
// MovesNothing is whether the delivery's plan moves no module and adds none: nothing for a walk to do. A
// delivery with no plan known is not said to move nothing.
// MovesNothing is whether the delivery's plan moves no module the mesh holds: nothing for a walk to do. A
// module it adds is not moved: the controller builds and registers it and sends it nowhere, and opens no walk
// for it (novox/hq issues 300 and 319). A delivery with no plan known is not said to move nothing.
func (d *Delivery) MovesNothing() bool {
return d.Plan != nil && len(d.Plan.Moved)+len(d.Plan.New) == 0
return d.Plan != nil && len(d.Plan.Moved) == 0
}
// Step is one machine's part of a delivering walk.
@@ -338,6 +346,8 @@ type WalkSeen struct {
Tier int `json:"tier"`
Tiers int `json:"tiers"`
Updated time.Time `json:"updated"`
// Branch is the branch the walk's commit was merged into; empty in a record kept before it was read.
Branch string `json:"branch,omitempty"`
}
// Seen is a walk as a delivery keeps it.
@@ -350,6 +360,7 @@ func (w Walk) Seen() *WalkSeen {
s.GoBy = d.By
s.StoppedBy = d.Stopped
}
s.Branch = w.Branch
return s
}
@@ -366,6 +377,13 @@ func (s *WalkSeen) LetGoByAPerson() bool { return s != nil && strings.HasPrefix(
// Open is whether the controller still works it.
func (w Walk) Open() bool { return w.State == walkBuilding || w.State == walkRolling }
// The steps that say a module reached every machine running it: sent to all at once, or to the rest after its
// first machines passed.
const (
stepEveryMachine = "every machine running it"
stepTheRest = "the rest running it"
)
// StepsOf are the machine steps a walk's record says, for one delivery: every module it moved, on its first
// machines from its gate, and the rest once sent.
func StepsOf(w Walk) []Step {
@@ -379,7 +397,7 @@ func StepsOf(w Walk) []Step {
m := w.Modules[name]
if m == nil || m.FirstAt == nil {
if m != nil && m.SentAt != nil && m.FirstAt == nil && m.Why == "" {
out = append(out, Step{Machine: "every machine running it", Module: name, Build: m.Build,
out = append(out, Step{Machine: stepEveryMachine, Module: name, Build: m.Build,
State: StepSent, Since: *m.SentAt})
}
continue
@@ -406,7 +424,7 @@ func StepsOf(w Walk) []Step {
out = append(out, Step{Machine: machine, Module: name, Build: m.Build, State: state, Since: since, Why: why})
}
if m.SentAt != nil && state == StepPassed {
out = append(out, Step{Machine: "the rest running it", Module: name, Build: m.Build, State: StepSent,
out = append(out, Step{Machine: stepTheRest, Module: name, Build: m.Build, State: StepSent,
Since: *m.SentAt})
}
}
@@ -76,6 +76,8 @@ type Forge interface {
// Statuses reads a commit's statuses — a pull request's head's, given its number — and whether the branch
// it merges into would let it merge. Reads only.
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
Contains(owner, repo, commit, in string) (bool, error)
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -337,6 +339,24 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil {
return false, err
}
answer, output, ok := answerOf(raw)
if !ok {
return false, fmt.Errorf("the forge refused gitea_contains: %s", lastLine(output))
}
var a struct {
Contains *bool `json:"contains"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Contains == nil {
return false, fmt.Errorf("the forge's gitea_contains is not readable: %s", clip(string(answer), 200))
}
return *a.Contains, nil
}
// kvStore is the module's own state: `deliveries` and `groups`, one key each.
type kvStore struct{}
@@ -34,17 +34,27 @@ const (
// replayed from the bus when it first held the seat. It reached the machines before this owner existed,
// and nothing is delivered, released or put back for it (novox/hq issue 313).
Retired State = "retired"
// Covered is a merge a later one that contains it delivered: every module it moves was sent to every machine
// running it by the finished walk of a later merge into the same trunk, whose own check passed and which the
// forge says contains this one. It is not walked itself, and it is no failure: its pull request says covered,
// as a success (novox/hq ADR 0270). A revert merged in between is still possible: covered says what was
// delivered, not what runs.
Covered State = "covered"
)
// AllStates are every state a delivery can be in, for the table's own test and its verb.
var AllStates = []State{Proposed, Checked, Ready, Rejected, Published, Delivering, Held, Delivered, Failed,
Superseded, Stopped, Retired}
Superseded, Stopped, Retired, Covered}
// Final is whether nothing follows a state.
func (s State) Final() bool {
return s == Delivered || s == Failed || s == Superseded || s == Stopped || s == Retired
return s == Delivered || s == Failed || s == Superseded || s == Stopped || s == Retired || s == Covered
}
// Reached is whether a final state says the change reached the machines, or had nothing to reach them
// with: what a group counts as delivered.
func (s State) Reached() bool { return s == Delivered || s == Retired || s == Covered }
// Event is what moves a delivery.
type Event string
@@ -72,6 +82,8 @@ const (
EvStop Event = "stop" // a person stopped it, or its group did
EvHistory Event = "history" // made from the forge's word for a merge long past: history
EvRetire Event = "retire" // a person's word that a held delivery is history, never to be walked
EvCovered Event = "covered" // a later merge into the same trunk delivered every module it moves
EvLifted Event = "lifted" // what held it for a person is no longer so: its group's composed check passed
)
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
@@ -91,6 +103,8 @@ type Row struct {
Holds func(d *Delivery, f Facts) error
// Act says the row is taken only when asked — a person's or H2's — never by settle.
Act bool
// Says is the transition's why when nobody gave one: the fact that moved it, rather than the guard.
Says func(d *Delivery) string
}
func unless(cond bool, why string) error {
@@ -161,6 +175,29 @@ var Table = []Row{
{From: []State{Checked}, Event: EvRefused, To: Rejected,
Guard: "the gate failed or could not run, or the repository's own check failed",
Holds: func(d *Delivery, f Facts) error { return unless(!d.Check.Passes(), "the verdict passes") }},
{From: []State{Proposed, Checked, Rejected, Held}, Event: EvDone, To: Delivered,
Guard: "merged, and its plan builds nothing for a walk to move — nothing at all, or only a new module the " +
"controller builds and sends nowhere: nothing to deliver and nothing for a person to decide, whatever its check said",
Holds: func(d *Delivery, f Facts) error {
return unless(d.MergedAs != "" && d.MovesNothing() && d.Walk == nil, "not merged, or a walk moves it")
},
Says: func(d *Delivery) string { return "merged, and nothing for a walk to move: " + d.Plan.Summary }},
{From: []State{Ready, Held}, Event: EvCovered, To: Covered,
Guard: "merged, no walk of its own open, and every module it moves sent to every machine running it by the " +
"finished walk of a later merge into the same trunk, whose own check passed and which the forge says contains " +
"it: a later merge that contains it was delivered",
Holds: func(d *Delivery, f Facts) error {
switch {
case d.MergedAs == "" || d.CoveredBy == "":
return errors.New("not merged, or nothing later carried its modules")
case d.Walk != nil && (d.Walk.State == walkBuilding || d.Walk.State == walkRolling):
return fmt.Errorf("its own walk %s is open: the controller delivers it", d.Walk.ID)
}
return nil
},
Says: func(d *Delivery) string {
return "covered: a later merge that contains it was delivered: " + d.CoveredBy
}},
{From: []State{Proposed, Checked, Rejected}, Event: EvMergedUnchecked, To: Held,
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides that it goes on",
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
@@ -208,6 +245,13 @@ var Table = []Row{
return unless(d.Walk != nil && d.Walk.Started() && (!d.Walk.Waited() || d.Walk.LetGoByAPerson()),
"its walk was not started by a person or the controller's own path")
}},
{From: []State{Held}, Event: EvLifted, To: Published,
Guard: "held only for its group's composed check, which has since passed or warned for the heads that merged: " +
"it is published again and waits for its turn",
Holds: func(d *Delivery, f Facts) error {
return unless(d.Lifted != "" && d.HeldWhy == "" && d.MergedAs != "" && walkCanGoOn(d), "nothing lifted its hold")
},
Says: func(d *Delivery) string { return "lifted: " + d.Lifted }},
{From: []State{Held}, Event: EvRelease, To: Delivering, Act: true,
Guard: "a person's decision, with why",
Holds: func(d *Delivery, f Facts) error {
@@ -240,6 +284,15 @@ var Table = []Row{
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "" && f.By != "", "a stop says who and why") }},
}
// walkCanGoOn is whether a delivery published again would be delivered by what it has: its own walk still
// open, or nothing for a walk to move.
func walkCanGoOn(d *Delivery) bool {
if d.Walk == nil {
return d.MovesNothing()
}
return d.Walk.State == walkBuilding || d.Walk.State == walkRolling
}
// historyLate is how long after its merge a merge heard only from the forge's word is history: the forge
// announces a merge within seconds, so one heard this late was replayed from the bus's history.
const historyLate = 15 * time.Minute
@@ -309,6 +362,9 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
return Transition{}, ErrRefused{ID: d.ID, From: d.State, Event: ev, Why: "its guard does not hold: " + err.Error()}
}
why := f.Why
if why == "" && r.Says != nil {
why = r.Says(d)
}
if why == "" {
why = r.Guard
}
@@ -55,11 +55,19 @@ func aDeliveryFor(r Row) (*Delivery, Facts) {
case EvHold:
d.HeldWhy, d.MergedAs = "its group's check did not pass", "aaaa1111"
case EvDone:
if r.From[0] == Published {
switch {
case r.From[0] == Published:
d.Plan = &DeliveryPlan{Summary: "moves nothing"}
} else {
case r.From[0] == Proposed:
d.MergedAs, d.Plan = "aaaa1111", &DeliveryPlan{Summary: "builds nothing"}
default:
d.Walk = &WalkSeen{ID: "plan-1", State: walkDone}
}
case EvCovered:
d.MergedAs, d.CoveredBy = "aaaa1111", "delivered with novox/app@bbbb (merged as bbbb2222, walk plan-2): app"
case EvLifted:
d.MergedAs, d.Lifted = "aaaa1111", "its group's composed check passed"
d.Walk = &WalkSeen{ID: "plan-1", State: walkBuilding, Waits: true, WaitedFor: "mesh-delivery"}
case EvFailed:
d.Walk = &WalkSeen{ID: "plan-1", State: walkFailed}
case EvSuperseded:
@@ -104,7 +112,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
events := []Event{EvAnnounced, EvAppeared, EvAdopted, EvChecked, EvAccepted, EvRefused, EvRecheck, EvReask, EvNewHead,
EvClosed, EvMerged, EvMergedUnchecked, EvGo, EvHold, EvRelease, EvDone, EvFailed, EvSuperseded, EvStop,
EvHistory, EvRetire}
EvHistory, EvRetire, EvCovered, EvLifted}
for _, from := range append([]State{None}, AllStates...) {
for _, ev := range events {
for _, act := range []bool{false, true} {
@@ -140,10 +148,16 @@ func TestTheTableKeepsItsRules(t *testing.T) {
t.Errorf("%s goes back to %s by %s", from, r.To, r.Event)
}
}
// The trunk rule: only a ready delivery is published, by its merge; nothing off the trunk delivers.
if r.To == Published && (r.Event != EvMerged || len(r.From) != 1 || r.From[0] != Ready) {
// The trunk rule: only a ready delivery is published, by its merge — or a held one published again when
// what held it is gone (issue 316); nothing off the trunk delivers.
if r.To == Published && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) &&
!(r.Event == EvLifted && len(r.From) == 1 && r.From[0] == Held) {
t.Errorf("%v —%s→ published: only a ready delivery's merge publishes", r.From, r.Event)
}
// Nothing a person did not decide ends a delivery as stopped or failed by observation from held.
if contains(r.From, Held) && !r.Act && (r.To == Stopped || r.To == Failed) {
t.Errorf("held —%s→ %s by observation", r.Event, r.To)
}
if r.To == Delivering {
for _, from := range r.From {
if from == Proposed || from == Checked || from == Ready || from == Rejected {
+2 -1
View File
@@ -43,7 +43,8 @@
"gitea.gitea_note_append",
"gitea.gitea_delivery_view",
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses"
"gitea.gitea_commit_statuses",
"gitea.gitea_contains"
],
"state": [
"deliveries",