The gate says whether a merged delivery is on the machines, derived from the view's reach, and a person settles a merge held without a passing check on the operator's answer (issue 1280, ADR 0556 clauses 4 and 5)
This commit is contained in:
@@ -579,7 +579,7 @@ func (h *Holder) hearAsks() {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
keys := h.pendingAsks()
|
||||
keys := append(h.pendingAsks(), h.settleKeys()...)
|
||||
h.mu.Unlock()
|
||||
for _, key := range keys {
|
||||
answer, err := h.Operator.Where(key)
|
||||
@@ -591,7 +591,12 @@ func (h *Holder) hearAsks() {
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
said := h.answered(key, *answer)
|
||||
var said []func()
|
||||
if h.isSettleKey(key) {
|
||||
h.settled(key, *answer) // a person's settle ask (settle.go, novox/hq ADR 0556 clause 5)
|
||||
} else {
|
||||
said = h.answered(key, *answer)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, s := range said {
|
||||
s()
|
||||
|
||||
@@ -89,6 +89,9 @@ type Holder struct {
|
||||
Turns TurnStore
|
||||
turnsHeld map[string]sdk.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]sdk.Reach
|
||||
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
|
||||
tried owedTries
|
||||
// forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have
|
||||
@@ -139,6 +142,7 @@ func (h *Holder) init() {
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
|
||||
h.reach = map[string]sdk.Reach{}
|
||||
h.started = h.Now()
|
||||
}
|
||||
|
||||
@@ -757,7 +761,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
|
||||
}
|
||||
ts = append(ts, settle(d, h.factsAt(now))...)
|
||||
if d.State == Held && d.HeldWhy == "" {
|
||||
d.HeldWhy = "merged without a passing check: only a person decides what follows"
|
||||
d.HeldWhy = HeldUnchecked
|
||||
}
|
||||
h.moved(d, ts)
|
||||
h.closeGroupOf(d)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to
|
||||
// every machine — and the delivery group above it: their states in one compiled table, kept in its own state
|
||||
// on the bus, every transition said as an event, noted on the commit and shown on the pull request through
|
||||
// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and derives nothing
|
||||
// after the merge (ADR 0539 §17). stdout
|
||||
// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and records nothing
|
||||
// after the merge (ADR 0539 §17): whether a merge is on the machines it derives when asked from the delivery view's reach
|
||||
// (ADR 0556 clause 4), and a merge held without a passing check is settled on the operator's answer (clause 5). stdout
|
||||
// is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
@@ -116,6 +117,21 @@ func run(h *Holder, l *listening) {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
// The delivery view's reach (novox/hq ADR 0556 clause 4): how far each repository reached, which `deliveries` and `show`
|
||||
// derive whether a merge is on the machines from. Each change is kept; nothing is moved by it.
|
||||
go func() {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := stdio.State(reachState).Watch("", func(c stdio.StateChange) error {
|
||||
h.ReachChanged(c.Key, c.Value, c.Op == "delete")
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logf("[mesh-delivery] not hearing the delivery view's reach yet (%v); asking again in %s", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}()
|
||||
// The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the
|
||||
// heads an ask covers, and a group's verdict is that one; each change asks for a look.
|
||||
go func() {
|
||||
@@ -290,7 +306,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
list := []stdio.Tool{
|
||||
{Name: seat + "deliveries",
|
||||
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
|
||||
"(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " +
|
||||
"(owner/repository@commit), its state, its group, what it waits for and since when, and for a merged one whether it is on " +
|
||||
"the machines (machines, derived from the delivery view's reach, novox/hq ADR 0556). Narrowed by state, " +
|
||||
"repository or group.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"),
|
||||
"repository": str("owner/repository"), "group": str("a group's id (its branch name)"),
|
||||
@@ -301,7 +318,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}},
|
||||
{Name: seat + "show",
|
||||
Description: "One delivery or group whole: every transition with when and why, and its group. " +
|
||||
"What the merge moved is source.main; whether it reached the machines is the view's.",
|
||||
"For a merged delivery, whether its merge is on the machines (machines), derived from the delivery view's reach of its " +
|
||||
"repository (novox/hq ADR 0556), and else what holds it.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")},
|
||||
"required": []string{"id"}},
|
||||
Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }},
|
||||
@@ -362,6 +380,30 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
}
|
||||
return h.Close(strArg(a, "id"), strArg(a, "why"))
|
||||
}},
|
||||
{Name: seat + "settle",
|
||||
Description: "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: " +
|
||||
"one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's " +
|
||||
"answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). " +
|
||||
"Refused, nothing asked, when any delivery named is not held or is held for another reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": str("why they may be settled, shown to the operator")}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var ids []string
|
||||
switch v := a["ids"].(type) {
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
ids = append(ids, s)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
ids = v
|
||||
}
|
||||
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 " +
|
||||
|
||||
@@ -70,8 +70,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if len(m.Seats) != 0 {
|
||||
t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own")
|
||||
}
|
||||
// The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans.
|
||||
if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close"}; !reflect.DeepEqual(m.Claims[0].Serves, want) {
|
||||
// The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans; and
|
||||
// settle, a person's ask to settle merges held without a passing check (ADR 0556 clause 5).
|
||||
if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close", "settle"}; !reflect.DeepEqual(m.Claims[0].Serves, want) {
|
||||
t.Fatalf("serves %v, wanted %v", m.Claims[0].Serves, want)
|
||||
}
|
||||
// R6-C1: no controller verb is invoked and no plan event is consumed.
|
||||
@@ -98,8 +99,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
t.Fatalf("state %v", buckets)
|
||||
}
|
||||
// What it reads: the teller's answers and the checker's verdicts (issue 1302). The carry from today's module's buckets, the
|
||||
// seat's holder and the machines' haves went with the seat's hand-over (ADR 0539 §17).
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts"}) {
|
||||
// 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.
|
||||
if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-delivery-view.reach"}) {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
// It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It
|
||||
|
||||
@@ -105,6 +105,10 @@ type Delivery struct {
|
||||
// trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked.
|
||||
FreshWanted bool `json:"fresh_wanted,omitempty"`
|
||||
|
||||
// Settle is the settle ask a person had written for it while it is held for a merge without a passing check (settle.go,
|
||||
// novox/hq ADR 0556 clause 5): its key in the gate's asks, why, who asked and when, and the operator's answer once read.
|
||||
Settle *SettleAsk `json:"settle,omitempty"`
|
||||
|
||||
Transitions []Transition `json:"transitions,omitempty"`
|
||||
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
|
||||
// delivery is kept, retried until done.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Whether a merge is on the machines (novox/hq ADR 0556 clause 4). The gate records only the merge (ADR 0539 §17, ADR 0429
|
||||
// §1): it reads the delivery view's reach of each repository, which names the repository's reached head (the newest merge every
|
||||
// node runs a build containing), and derives when asked, for a merged delivery, whether its merge is at or before that head.
|
||||
// The order of two merges into one trunk is the gate's own: the moment each was merged, the trunk being a line of merges. A
|
||||
// reached head the gate holds no merge of is said, never asked of the forge.
|
||||
|
||||
// reachState is the delivery view's state the gate reads.
|
||||
const reachState = sdk.ViewModule + ".reach"
|
||||
|
||||
// ReachChanged takes one change of the view's reach.
|
||||
func (h *Holder) ReachChanged(key string, value []byte, deleted bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if deleted {
|
||||
delete(h.reach, key)
|
||||
return
|
||||
}
|
||||
r, err := sdk.DecodeReach(value)
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the view's reach %s is not readable, so its merges read as not known: %v", key, err)
|
||||
delete(h.reach, key)
|
||||
return
|
||||
}
|
||||
h.reach[key] = r
|
||||
}
|
||||
|
||||
// The words a merged delivery's machines are said in.
|
||||
const (
|
||||
onMachines = "on the machines"
|
||||
notYetOn = "not yet on the machines: "
|
||||
notKnownWhere = "not known: "
|
||||
)
|
||||
|
||||
// machines says whether a merged delivery's merge is on the machines, derived from the view's reach; empty for a delivery
|
||||
// that is not merged. Under the lock.
|
||||
func (h *Holder) machines(d *Delivery) string {
|
||||
if d.State != Merged || d.MergedAs == "" {
|
||||
return ""
|
||||
}
|
||||
key, err := sdk.RepositoryKey(d.Repository)
|
||||
if err != nil {
|
||||
return notKnownWhere + err.Error()
|
||||
}
|
||||
r, ok := h.reach[key]
|
||||
switch {
|
||||
case !ok:
|
||||
return notKnownWhere + "the delivery view says no reach of " + d.Repository
|
||||
case r.Reached == "":
|
||||
return notYetOn + holdsSaid(r)
|
||||
case sameCommit(d.MergedAs, r.Reached):
|
||||
return onMachines
|
||||
}
|
||||
head := h.mergeOf(d.Repository, r.Reached)
|
||||
switch {
|
||||
case head == nil:
|
||||
return notKnownWhere + fmt.Sprintf("the reached head %s of %s is no merge this gate holds", shortOf(r.Reached, 12), d.Repository)
|
||||
case !d.MergedAt.After(head.MergedAt):
|
||||
return onMachines
|
||||
}
|
||||
return notYetOn + holdsSaid(r)
|
||||
}
|
||||
|
||||
// holdsSaid is what holds a repository's next head, in words.
|
||||
func holdsSaid(r sdk.Reach) string {
|
||||
if len(r.Holds) == 0 {
|
||||
return "the delivery view names nothing that holds it yet"
|
||||
}
|
||||
return strings.Join(r.Holds, "; ")
|
||||
}
|
||||
|
||||
// mergeOf is the delivery of a repository that merged as the commit; nil when none does. Under the lock.
|
||||
func (h *Holder) mergeOf(repository, commit string) *Delivery {
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs != "" && strings.EqualFold(d.Repository, repository) && sameCommit(d.MergedAs, commit) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 1280 (novox/hq ADR 0556 clause 4): `deliveries` and `show` say for a merged delivery whether it is on the machines, derived
|
||||
// when asked from the view's reach and the gate's own order of merges: at or before the reached head it is, after it not yet
|
||||
// and why; a repository the view has no reach of, or a reached head the gate holds no merge of, is not known. Nothing records it.
|
||||
func TestAMergedDeliverySaysWhetherItIsOnTheMachines(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
at := w.now.Add(-3 * time.Hour)
|
||||
merge := func(commit string, n int) *Delivery {
|
||||
d := &Delivery{ID: "novox/web@" + commit[:12], Repository: "novox/web", Commit: commit[:12], Number: n, State: Merged,
|
||||
Since: at, MergedAs: strings.Repeat(commit[:1], 40), MergedAt: at.Add(time.Duration(n) * time.Minute)}
|
||||
w.h.mu.Lock()
|
||||
w.h.keep(d)
|
||||
w.h.mu.Unlock()
|
||||
return d
|
||||
}
|
||||
first, second, third := merge("111111111111", 1), merge("222222222222", 2), merge("333333333333", 3)
|
||||
reach := func(r sdk.Reach) {
|
||||
key, _ := sdk.RepositoryKey(r.Repository)
|
||||
raw, err := sdk.EncodeReach(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.ReachChanged(key, raw, false)
|
||||
}
|
||||
machines := func(d *Delivery) string { w.h.mu.Lock(); defer w.h.mu.Unlock(); return w.h.machines(d) }
|
||||
if got := machines(first); !strings.HasPrefix(got, notKnownWhere) {
|
||||
t.Errorf("with no reach: %q", got)
|
||||
}
|
||||
reach(sdk.Reach{Repository: "novox/web", Reached: second.MergedAs, ReachedSrc: 7, Holds: []string{"web on g14 runs the build of 2222, not yet that of 3333"}})
|
||||
for d, want := range map[*Delivery]string{first: onMachines, second: onMachines, third: notYetOn + "web on g14"} {
|
||||
if got := machines(d); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("%s: %q, want %q", d.ID, got, want)
|
||||
}
|
||||
}
|
||||
reach(sdk.Reach{Repository: "novox/web", Reached: strings.Repeat("9", 40), ReachedSrc: 8})
|
||||
if got := machines(first); !strings.Contains(got, "no merge this gate holds") {
|
||||
t.Errorf("a reached head the gate holds no merge of: %q", got)
|
||||
}
|
||||
reach(sdk.Reach{Repository: "novox/web", Holds: []string{"web on ace runs nothing yet"}})
|
||||
if got := machines(first); got != notYetOn+"web on ace runs nothing yet" {
|
||||
t.Errorf("no reached head: %q", got)
|
||||
}
|
||||
// The verbs say it; a delivery not merged says nothing of the machines.
|
||||
reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9})
|
||||
lines := w.h.Deliveries("", "novox/web", "", true)
|
||||
if len(lines) != 3 || lines[0].Machines != onMachines {
|
||||
t.Errorf("deliveries = %+v", lines)
|
||||
}
|
||||
shown, err := w.h.Show(third.ID)
|
||||
if err != nil || shown.(map[string]any)["machines"] != onMachines {
|
||||
t.Errorf("show = %v, %v", shown, err)
|
||||
}
|
||||
if got := machines(&Delivery{State: Held, MergedAs: "x", Repository: "novox/web"}); got != "" {
|
||||
t.Errorf("a held delivery says %q of the machines", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A person settles a delivery held because its merge reached the trunk without a passing check (novox/hq ADR 0556 clause 5).
|
||||
// The verb `settle` moves nothing: it writes one ask, for every delivery named, into the gate's asks, which the teller puts to
|
||||
// the operator as it puts an Approve, its options binding mesh-delivery.settle (sdk.AskVerb). On the answer settle each
|
||||
// delivery takes the table's asked row held → merged with the operator's words; on leave they stay held. Whether a settled
|
||||
// merge is on the machines is then derived as for any merge (reached.go). A hold of another kind, or a delivery not held, is
|
||||
// refused, naming why.
|
||||
|
||||
// The settle ask's two answers, both at the level approve.
|
||||
const (
|
||||
OptionSettle = "settle"
|
||||
OptionLeave = "leave"
|
||||
)
|
||||
|
||||
// SettleAsk is the settle ask a delivery is covered by.
|
||||
type SettleAsk struct {
|
||||
Key string `json:"key"`
|
||||
Asking int `json:"asking,omitempty"`
|
||||
Why string `json:"why"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Answered *time.Time `json:"answered,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// Settle is the `settle` verb: one ask of the operator for every delivery named, each held for a merge without a passing
|
||||
// check. Nothing moves until the operator answers.
|
||||
func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
|
||||
why = strings.TrimSpace(why)
|
||||
if why == "" {
|
||||
return nil, fmt.Errorf("settle says why: it is shown to the operator")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("settle names at least one held delivery (ids)")
|
||||
}
|
||||
if h.Operator == nil {
|
||||
return nil, fmt.Errorf("this owner has no way to ask the operator")
|
||||
}
|
||||
h.mu.Lock()
|
||||
var covered []*Delivery
|
||||
var refused []string
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
d := h.deliveries[id]
|
||||
switch {
|
||||
case d == nil:
|
||||
refused = append(refused, id+": no such delivery")
|
||||
case d.State != Held:
|
||||
refused = append(refused, fmt.Sprintf("%s: it is %s, not held", id, d.State))
|
||||
case !heldUnchecked(d):
|
||||
refused = append(refused, fmt.Sprintf("%s: it is held for another reason, which settle does not take (%s)", id, d.HeldWhy))
|
||||
default:
|
||||
covered = append(covered, d)
|
||||
}
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
h.mu.Unlock()
|
||||
sort.Strings(refused)
|
||||
return nil, fmt.Errorf("settle refused, nothing asked: %s", strings.Join(refused, "; "))
|
||||
}
|
||||
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
|
||||
var names []string
|
||||
var heads []sdk.Head
|
||||
for _, d := range covered {
|
||||
names = append(names, d.ID)
|
||||
heads = append(heads, sdk.Head{Repository: d.Repository, Number: d.Number, Head: d.Commit})
|
||||
}
|
||||
group := sdk.SettleGroup(names)
|
||||
key, err := sdk.MergeAskKey(group, heads)
|
||||
if err != nil {
|
||||
h.mu.Unlock()
|
||||
return nil, fmt.Errorf("the settle ask has no key: %w", err)
|
||||
}
|
||||
now := h.Now()
|
||||
// The same deliveries asked again (the ask lapsed, or was left unanswered) are a new asking of the same key, under its own
|
||||
// router id: the router drops a second ask under an id it kept.
|
||||
asking := 1 // the first asking (sdk.Ask.Asking: 0 or 1 is the first, n the n-th)
|
||||
for _, d := range covered {
|
||||
if d.Settle != nil && d.Settle.Key == key && max(d.Settle.Asking, 1) >= asking {
|
||||
asking = max(d.Settle.Asking, 1) + 1
|
||||
}
|
||||
}
|
||||
ask := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: composeSettleAsk(sdk.AskingID(key, asking), covered, why, now)}
|
||||
for _, d := range covered {
|
||||
d.Settle = &SettleAsk{Key: key, Asking: asking, Why: why, By: by, At: now}
|
||||
h.keep(d)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if err := h.Operator.Ask(key, ask); err != nil {
|
||||
return nil, fmt.Errorf("the settle ask %s could not be written (call settle again): %w", key, err)
|
||||
}
|
||||
h.Logf("[mesh-delivery] asked the operator to settle %s: %s", strings.Join(names, ", "), why)
|
||||
return map[string]any{"asked": key, "deliveries": names,
|
||||
"said": fmt.Sprintf("The operator is asked once to settle %d deliveries; nothing moves until the answer.", len(covered))}, nil
|
||||
}
|
||||
|
||||
// settleAct is what a settle ask's answer binds: its verb (mesh-delivery.settle), this ask, the answer and the heads.
|
||||
func settleAct(group, ask, option string, heads map[string]string) asks.Act {
|
||||
return asks.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
|
||||
}
|
||||
|
||||
// composeSettleAsk is the operator's ask to settle these held deliveries.
|
||||
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks.Ask {
|
||||
var a asks.Ask
|
||||
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
|
||||
a.OnExpiry = "they stay held; settle asks again"
|
||||
a.About = "delivery.settle"
|
||||
var pulls, lines []string
|
||||
heads := map[string]string{}
|
||||
for _, d := range ds {
|
||||
pulls = append(pulls, pullSaid(d))
|
||||
heads[d.ID] = d.Commit
|
||||
lines = append(lines, phone(fmt.Sprintf("%s %q merged at %s without a passing check", pullSaid(d), d.Title,
|
||||
shortOf(d.MergedAs, 12)), 400))
|
||||
}
|
||||
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), asks.HeadlineLength)
|
||||
a.Explanation = strings.Join([]string{
|
||||
phone(fmt.Sprintf("%s reached main without a passing check and are held. Settle ends the hold: each reads merged, and on the machines once every machine runs it. Leave keeps them held.",
|
||||
strings.Join(pulls, ", ")), asks.SummaryLength),
|
||||
phone("Why: "+why, asks.PointLength),
|
||||
}, "\n")
|
||||
a.Details = strings.Join(lines, "\n")
|
||||
group := sdk.SettleGroup(func() []string {
|
||||
var ids []string
|
||||
for _, d := range ds {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
return ids
|
||||
}())
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionSettle, "Settle", "each delivery named ends its hold and reads merged"},
|
||||
{OptionLeave, "Leave", "they stay held"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(settleAct(group, id, o.id, heads))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// isSettleKey is whether an ask key is a settle ask's; under the lock.
|
||||
func (h *Holder) isSettleKey(key string) bool {
|
||||
_, group, _, err := sdk.ParseAskKey(key)
|
||||
return err == nil && strings.HasPrefix(group, sdk.SettleGroupPrefix)
|
||||
}
|
||||
|
||||
// settleKeys are the settle asks waiting for an answer; under the lock.
|
||||
func (h *Holder) settleKeys() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
if s := d.Settle; s != nil && s.Answered == nil && d.State == Held && !seen[s.Key] {
|
||||
seen[s.Key] = true
|
||||
out = append(out, s.Key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// settled applies the teller's answer to a settle ask; under the lock. The teller held the warrant (the act it bound is
|
||||
// mesh-delivery.settle); an answer to an earlier asking, or one choosing what the ask did not offer, moves nothing.
|
||||
func (h *Holder) settled(key string, ans sdk.Answer) {
|
||||
var covered []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.Settle != nil && d.Settle.Key == key && d.Settle.Answered == nil {
|
||||
covered = append(covered, d)
|
||||
}
|
||||
}
|
||||
if len(covered) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
|
||||
if asked, at, ok := strings.Cut(ans.Warrant, "@"); ok && at != "" && asked != sdk.AskingID(key, covered[0].Settle.Asking) {
|
||||
h.Logf("[mesh-delivery] the answer to the settle ask %s was given to another asking (%s): not taken", key, asked)
|
||||
return
|
||||
}
|
||||
if ans.Option != OptionSettle && ans.Option != OptionLeave {
|
||||
h.refuse(fmt.Errorf("the answer to the settle ask %s chose %s, which it did not offer: nothing settled", key, ans.Option))
|
||||
return
|
||||
}
|
||||
now := h.Now()
|
||||
for _, d := range covered {
|
||||
d.Settle.Answered, d.Settle.Answer, d.Settle.Words = &now, ans.Option, ans.Words
|
||||
if ans.Option == OptionLeave {
|
||||
h.Logf("[mesh-delivery] %s: the operator left it held (%s)", d.ID, ans.Words)
|
||||
h.keep(d)
|
||||
continue
|
||||
}
|
||||
t, err := Apply(d, EvSettled, true, Facts{Now: now, By: "the operator", Why: "settled: " + ans.Words})
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
h.keep(d)
|
||||
continue
|
||||
}
|
||||
d.HeldWhy = ""
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 1280 (novox/hq ADR 0556 clause 5): settle writes one ask for several deliveries held for a merge without a passing check and
|
||||
// moves none of them; it refuses, asking nothing, a delivery held for another reason and one not held, naming why; the
|
||||
// operator's Leave keeps them held, and Settle, asked again under a new asking, moves each to merged with the operator's words.
|
||||
func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
merged := w.now.Add(-time.Hour)
|
||||
put := func(id string, state State, why string) *Delivery {
|
||||
repo, commit, _ := strings.Cut(id, "@")
|
||||
d := &Delivery{ID: id, Repository: repo, Commit: commit, Number: len(id), Title: "a fix", State: state, Since: merged,
|
||||
MergedAs: "m" + commit, MergedAt: merged, HeldWhy: why}
|
||||
w.h.mu.Lock()
|
||||
w.h.keep(d)
|
||||
w.h.mu.Unlock()
|
||||
return d
|
||||
}
|
||||
a := put("novox/web@aaaaaaaaaaaa", Held, HeldUnchecked)
|
||||
b := put("novox/sdk@bbbbbbbbbbbb", Held, HeldUnchecked)
|
||||
put("novox/web@cccccccccccc", Held, "a conflict on the trunk")
|
||||
put("novox/web@dddddddddddd", Merged, "")
|
||||
for ids, words := range map[string]string{
|
||||
"novox/web@aaaaaaaaaaaa novox/web@cccccccccccc": "held for another reason",
|
||||
"novox/web@aaaaaaaaaaaa novox/web@dddddddddddd": "not held",
|
||||
"novox/web@aaaaaaaaaaaa novox/web@eeeeeeeeeeee": "no such delivery",
|
||||
} {
|
||||
if _, err := w.h.Settle(strings.Fields(ids), "on every machine since 10-25", aPerson); err == nil || !strings.Contains(err.Error(), words) {
|
||||
t.Errorf("settle %s: %v, want refused naming %q", ids, err, words)
|
||||
}
|
||||
}
|
||||
if _, err := w.h.Settle([]string{a.ID}, " ", aPerson); err == nil {
|
||||
t.Error("a settle with no why was asked")
|
||||
}
|
||||
if w.op.count() != 0 {
|
||||
t.Fatalf("a refused settle asked the operator: %d asks", w.op.count())
|
||||
}
|
||||
out, err := w.h.Settle([]string{b.ID, a.ID}, "on every machine since 10-25", aPerson)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := out["asked"].(string)
|
||||
if open := w.op.open(); len(open) != 1 || open[0] != key || w.op.count() != 1 {
|
||||
t.Fatalf("asks open %v (asked %d), want the one key %s", open, w.op.count(), key)
|
||||
}
|
||||
ask := w.op.ask(key)
|
||||
heads := map[string]string{a.ID: a.Commit, b.ID: b.Commit}
|
||||
group := sdk.SettleGroup([]string{a.ID, b.ID})
|
||||
for _, o := range ask.Options {
|
||||
if want, _ := asks.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
|
||||
t.Errorf("the option %s binds %s, not mesh-delivery.settle of these heads", o.ID, o.Binds)
|
||||
}
|
||||
}
|
||||
w.h.hearAsks()
|
||||
if a.State != Held || b.State != Held {
|
||||
t.Fatalf("moved before an answer: %s, %s", a.State, b.State)
|
||||
}
|
||||
w.op.answer(key, OptionLeave)
|
||||
w.h.hearAsks()
|
||||
if a.State != Held || a.Settle == nil || a.Settle.Answer != OptionLeave {
|
||||
t.Fatalf("after Leave: %s %+v", a.State, a.Settle)
|
||||
}
|
||||
// Asked again: a new asking of the same key, under its own id.
|
||||
if _, err := w.h.Settle([]string{a.ID, b.ID}, "asked again", aPerson); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := w.op.ask(key); again.ID == ask.ID || a.Settle.Asking != 2 {
|
||||
t.Fatalf("asked again under %s (asking %d), want a new id", again.ID, a.Settle.Asking)
|
||||
}
|
||||
w.op.answer(key, OptionSettle)
|
||||
w.h.hearAsks()
|
||||
for _, d := range []*Delivery{a, b} {
|
||||
last := d.Transitions[len(d.Transitions)-1]
|
||||
if d.State != Merged || last.Event != EvSettled || last.By != "the operator" || !strings.Contains(last.Why, "chose Settle") {
|
||||
t.Errorf("%s after Settle: %s, %+v", d.ID, d.State, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,18 @@ const (
|
||||
EvMerged Event = "merged" // it reached the trunk
|
||||
EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check
|
||||
EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323)
|
||||
EvSettled Event = "settled" // the operator settled a merge held without a passing check (novox/hq ADR 0556 clause 5)
|
||||
)
|
||||
|
||||
// HeldUnchecked is the why of a delivery held because its merge reached the trunk without a passing check: the one hold a
|
||||
// person may settle (novox/hq ADR 0556 clause 5).
|
||||
const HeldUnchecked = "merged without a passing check: only a person decides what follows"
|
||||
|
||||
// heldUnchecked is whether a held delivery is held for a merge without a passing check, and reached the trunk.
|
||||
func heldUnchecked(d *Delivery) bool {
|
||||
return d.State == Held && d.MergedAs != "" && strings.HasPrefix(d.HeldWhy, "merged without a passing check")
|
||||
}
|
||||
|
||||
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
|
||||
type Facts struct {
|
||||
Now time.Time
|
||||
@@ -177,6 +187,20 @@ var Table = []Row{
|
||||
{From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held,
|
||||
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
|
||||
// **A person settles a merge held without a passing check** (novox/hq ADR 0556 clause 5): only on the operator's answer to
|
||||
// the settle ask, with its words; a hold of another kind is not taken here. Whether it is on the machines is then derived.
|
||||
{From: []State{Held}, Event: EvSettled, To: Merged, Act: true,
|
||||
Guard: "held because its merge reached the trunk without a passing check, and the operator answered settle to the ask " +
|
||||
"`settle` wrote, with the words of that answer",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
switch {
|
||||
case !heldUnchecked(d):
|
||||
return errors.New("it is not held for a merge without a passing check: " + d.HeldWhy)
|
||||
case f.By == "" || f.Why == "":
|
||||
return errors.New("a settle names the operator's answer and its words")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true,
|
||||
Guard: "a person asked, with why, or its group changed",
|
||||
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }},
|
||||
|
||||
@@ -34,6 +34,9 @@ func aDeliveryFor(r Row) (*Delivery, Facts) {
|
||||
case EvTrunkMoved:
|
||||
d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"}
|
||||
d.TrunkNow = "690966dccccccccc"
|
||||
case EvSettled:
|
||||
d.MergedAs, d.HeldWhy = "aaaa1111", HeldUnchecked
|
||||
f.Why, f.By = "settled: the operator chose Settle", "the operator"
|
||||
}
|
||||
return d, f
|
||||
}
|
||||
@@ -103,7 +106,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.
|
||||
|
||||
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
|
||||
events := []Event{EvAnnounced, EvAccepted, EvRefused, EvRecheck, EvReask, EvLost, EvNewHead, EvClosed, EvMerged,
|
||||
EvMergedUnchecked, EvTrunkMoved}
|
||||
EvMergedUnchecked, EvTrunkMoved, EvSettled}
|
||||
for _, from := range append([]State{None}, AllStates...) {
|
||||
for _, ev := range events {
|
||||
for _, act := range []bool{false, true} {
|
||||
@@ -128,14 +131,17 @@ func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
|
||||
// The rules the table is for, read off the table.
|
||||
func TestTheTableKeepsItsRules(t *testing.T) {
|
||||
for _, r := range Table {
|
||||
// The one row out of a final state is a person's settle of a merge held without a passing check, taken on the
|
||||
// operator's answer (novox/hq ADR 0556 clause 5): it is already on the trunk, and moves from held to merged.
|
||||
settle := r.Event == EvSettled && r.Act && len(r.From) == 1 && r.From[0] == Held && r.To == Merged
|
||||
for _, from := range r.From {
|
||||
// Nothing leaves a final state.
|
||||
if from.Final() {
|
||||
// Nothing else leaves a final state.
|
||||
if from.Final() && !settle {
|
||||
t.Errorf("%s is final and the table leaves it by %s", from, r.Event)
|
||||
}
|
||||
}
|
||||
// Only a ready delivery is merged, and nothing but its merge, or a merge without a passing check, puts one on the trunk.
|
||||
if r.To == Merged && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) {
|
||||
if r.To == Merged && !settle && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) {
|
||||
t.Errorf("%v —%s→ merged: only a ready delivery's merge merges", r.From, r.Event)
|
||||
}
|
||||
if r.To == Held && r.Event != EvMergedUnchecked {
|
||||
|
||||
@@ -22,6 +22,9 @@ type Line struct {
|
||||
Waits string `json:"waits,omitempty"`
|
||||
Owed int `json:"owed,omitempty"`
|
||||
Stalled bool `json:"stalled,omitempty"`
|
||||
// Machines says, for a merged delivery, whether its merge is on the machines, derived from the delivery view's reach
|
||||
// (reached.go, novox/hq ADR 0556 clause 4); recorded nowhere.
|
||||
Machines string `json:"machines,omitempty"`
|
||||
}
|
||||
|
||||
// waitsFor is what a delivery waits for, in words.
|
||||
@@ -61,7 +64,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
|
||||
continue
|
||||
}
|
||||
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
|
||||
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed)}
|
||||
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed), Machines: h.machines(d)}
|
||||
l.Stalled = pastBound(d, now)
|
||||
out = append(out, l)
|
||||
}
|
||||
@@ -80,6 +83,9 @@ func (h *Holder) Show(id string) (any, error) {
|
||||
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()}
|
||||
if m := h.machines(d); m != "" {
|
||||
out["machines"] = m
|
||||
}
|
||||
if g := h.groups[d.Group]; g != nil {
|
||||
state, why, _ := h.groupState(g, h.membersOf(g))
|
||||
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
||||
@@ -326,5 +332,9 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
// HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to
|
||||
// read; the gate moves nothing after the merge.
|
||||
func HeldLine(d *Delivery) string {
|
||||
return fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy)
|
||||
line := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy)
|
||||
if heldUnchecked(d) {
|
||||
line += " (mesh-delivery.settle asks the operator to settle it, ADR 0556)"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"table",
|
||||
"stalled",
|
||||
"recheck",
|
||||
"close"
|
||||
"close",
|
||||
"settle"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -70,7 +71,8 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-teller.answers",
|
||||
"mesh-delivery-checker.verdicts"
|
||||
"mesh-delivery-checker.verdicts",
|
||||
"mesh-delivery-view.reach"
|
||||
],
|
||||
"tools": [
|
||||
"delivery_hand_now",
|
||||
|
||||
Reference in New Issue
Block a user