Merge pull request 'mesh-delivery: give a waiting walk its word or say what holds it (issues 529, 362)' (#229) from fix/529-a-waiting-walk-is-answered-or-named into main
This commit was merged in pull request #229.
This commit is contained in:
@@ -366,6 +366,7 @@ func (h *Holder) words() []func() {
|
||||
}
|
||||
sort.Strings(walks)
|
||||
var asks []func()
|
||||
h.waitsWhy = map[string]string{}
|
||||
for _, walk := range walks {
|
||||
want := h.turning[walk]
|
||||
var waiting []string
|
||||
@@ -377,14 +378,16 @@ func (h *Holder) words() []func() {
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
sort.Strings(waiting)
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go yet: %s carried by it not at its turn", walk,
|
||||
strings.Join(waiting, ", "))
|
||||
h.waitsWhy[walk] = strings.Join(waiting, ", ") + " carried by it not at its turn"
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go yet: %s", walk, h.waitsWhy[walk])
|
||||
continue
|
||||
}
|
||||
ids, why := append([]string(nil), want.ids...), strings.Join(want.whys, "; ")
|
||||
// **Never the word for a walk that carries a stopped change** (ADR 0299, review of #221): it is asked to
|
||||
// leave the merge out first (leaveStopped).
|
||||
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
|
||||
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
|
||||
"asked to leave that merge out first"
|
||||
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
|
||||
strings.Join(stopped, ", "))
|
||||
continue
|
||||
@@ -419,3 +422,94 @@ func (h *Holder) words() []func() {
|
||||
h.turning = nil
|
||||
return asks
|
||||
}
|
||||
|
||||
// endedMovingNothing is whether a delivery ended delivered with nothing for a walk to move: merged, its plan
|
||||
// moving no module the mesh holds, and no walk followed.
|
||||
func endedMovingNothing(d *Delivery) bool {
|
||||
return d.State == Delivered && d.MergedAs != "" && d.Walk == nil && d.MovesNothing()
|
||||
}
|
||||
|
||||
// endedAsNothingToMove are the deliveries a walk answers that ended delivered as nothing for a walk to move (novox/hq
|
||||
// issues 362, 529: the nzbgeek case); empty when it answers any other. Such a walk is held by nothing: said, never
|
||||
// let go by this owner. Under the lock.
|
||||
func (h *Holder) endedAsNothingToMove(w Walk) []string {
|
||||
var out []string
|
||||
for _, d := range h.deliveries {
|
||||
_, answers := walkAnswers(w, d)
|
||||
if !answers && (d.Walk == nil || d.Walk.ID != w.ID) {
|
||||
continue
|
||||
}
|
||||
if !endedMovingNothing(d) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, d.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// WalksWaiting is `stalled` asked with `walks` (novox/hq issue 529): for each walk waiting for this owner's word,
|
||||
// what holds it, in the shape of a stalled line naming the walk — each held delivery it carries (State held, its
|
||||
// reason and the verb that releases it), else why its word was not given at the last tick (State published), else
|
||||
// that no live delivery holds it (State unheld). A walk whose word is being given is not listed. The controller's
|
||||
// `plans` says these instead of pointing at a hand start; the self-check never raises a condition for them.
|
||||
func (h *Holder) WalksWaiting() []StalledLine {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := h.Now()
|
||||
var out []StalledLine
|
||||
for id, s := range h.walks {
|
||||
w := s.walk
|
||||
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits || h.asking[id] {
|
||||
continue
|
||||
}
|
||||
waited := now.Sub(w.Created).Round(time.Second).String()
|
||||
var held []StalledLine
|
||||
live := false
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.Walk == nil || d.Walk.ID != id {
|
||||
continue
|
||||
}
|
||||
live = true
|
||||
if d.State == Held {
|
||||
held = append(held, StalledLine{ID: d.ID, Number: d.Number, State: Held, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: held for a person — the operator's", Says: HeldLine(d)})
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(held) > 0:
|
||||
out = append(out, held...)
|
||||
case h.waitsWhy[id] != "":
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]})
|
||||
case !live:
|
||||
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
|
||||
"`mesh-delivery.show` for the deliveries of " + w.Repository
|
||||
if ended := h.endedAsNothingToMove(w); len(ended) > 0 {
|
||||
// The nzbgeek case (novox/hq issues 362, 529): its merges' deliveries ended as nothing for a walk to
|
||||
// move, yet the walk moves modules — nothing judged those, so nothing here lets it go.
|
||||
says = "held by nothing: its merges' deliveries ended as nothing-to-move (" + strings.Join(ended, ", ") +
|
||||
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
|
||||
}
|
||||
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited,
|
||||
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Walk != out[j].Walk {
|
||||
return out[i].Walk < out[j].Walk
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// walkModules are the modules a walk's record names, sorted.
|
||||
func walkModules(w Walk) []string {
|
||||
out := make([]string, 0, len(w.Modules))
|
||||
for m := range w.Modules {
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ type Holder struct {
|
||||
walks map[string]seenWalk
|
||||
orphans map[string]time.Time
|
||||
turning map[string]*turnWant
|
||||
// waitsWhy is, by walk, why the last tick did not give a waiting walk its word: a delivery it carries not at
|
||||
// its turn, or a stopped change it carries (novox/hq issue 529). Said by WalksWaiting.
|
||||
waitsWhy map[string]string
|
||||
// asking are the walks whose word is being asked right now, outside the lock: not asked twice meanwhile.
|
||||
asking map[string]bool
|
||||
// leaving are, by walk, the merges a stop gathered to leave out of it, asked once (novox/hq ADR 0299).
|
||||
@@ -103,6 +106,7 @@ func (h *Holder) init() {
|
||||
h.written = map[string][]byte{}
|
||||
h.dirtyGroup = map[string]bool{}
|
||||
h.unmatched = map[string]unmatched{}
|
||||
h.waitsWhy = map[string]string{}
|
||||
h.askTried = map[string]time.Time{}
|
||||
h.walkAsked = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 362's note of 2026-10-11 (the nzbgeek case) and issue 529: mesh-media-catalog #18 added a module
|
||||
// whose delivery plan said "sent nowhere". This owner ended its delivery at once, "nothing for a walk to move", but
|
||||
// the controller opened a walk for the merge that moved modules to a machine and waited for this owner's word.
|
||||
// With its delivery ended, nothing gave the walk its word: it held 39 minutes and two walks queued behind it.
|
||||
|
||||
const (
|
||||
media = "novox/mesh-media-catalog"
|
||||
headNzb = "abababab00001818"
|
||||
mergeNzb = "cdcdcdcd00001818"
|
||||
nzbWalkID = "plan-1791748193836841649"
|
||||
)
|
||||
|
||||
var atNzb = time.Date(2026, 10, 11, 19, 43, 0, 0, time.UTC)
|
||||
|
||||
// nzbgeekMerged is #18 checked on a plan that builds a new module and sends it nowhere, and merged: its delivery
|
||||
// ends at once, nothing for a walk to move.
|
||||
func nzbgeekMerged(t *testing.T) *world {
|
||||
t.Helper()
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atNzb.Add(-time.Minute))
|
||||
p := pr(media, 18, headNzb, "feat/nzbgeek", "modules/nzbgeek/module.json")
|
||||
w.h.PullUpdated(p)
|
||||
v := verdict(media, 18, headNzb, "pass")
|
||||
v.Plan = &DeliveryPlan{Repository: media, Base: "main", New: []string{"modules/nzbgeek"},
|
||||
Summary: "builds new: modules/nzbgeek, sent nowhere; no bus step"}
|
||||
w.h.Checked(v)
|
||||
w.later(time.Minute)
|
||||
w.h.PullMerged(mergedAt(p, mergeNzb, atNzb))
|
||||
w.settleAll()
|
||||
if d := w.delivery(IDOf(media, headNzb)); d.State != Delivered {
|
||||
t.Fatalf("#18 is %s, not delivered as nothing for a walk to move", d.State)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// nzbWalk is the walk the controller cut for the merge's batch: it names the merge and moves nzbgeek and nzbget.
|
||||
func nzbWalk(created time.Time, merges ...WalkMerge) Walk {
|
||||
w := Walk{ID: nzbWalkID, Repository: media, Branch: "main", Commit: mergeNzb, Created: created, State: walkBuilding,
|
||||
Tiers: [][]string{{"nzbgeek", "nzbget"}}, Modules: map[string]*WalkModule{"nzbgeek": {}, "nzbget": {}},
|
||||
Delivery: &WalkDelivery{Awaits: "mesh-delivery", Merges: merges}}
|
||||
return w
|
||||
}
|
||||
|
||||
// **A walk answering only merges that ended as nothing for a walk to move is named as held by nothing** (novox/hq
|
||||
// issues 362 and 529): never let go by this owner — nothing judged what it moves — and said, by `stalled` with
|
||||
// `walks`, with why, so the controller's `plans` says so instead of pointing nowhere.
|
||||
func TestAWalkOfNothingToMoveMergesIsNamedAsHeldByNothing(t *testing.T) {
|
||||
w := nzbgeekMerged(t)
|
||||
w.later(90 * time.Second)
|
||||
w.h.WalkMoved(w.ctl.put(nzbWalk(w.now, WalkMerge{Repository: media, Commit: mergeNzb})))
|
||||
w.settleAll()
|
||||
if len(w.ctl.delivers) != 0 {
|
||||
t.Fatalf("a walk no live delivery holds was let go: %v", w.ctl.delivers)
|
||||
}
|
||||
lines := w.h.WalksWaiting()
|
||||
if len(lines) != 1 || lines[0].Walk != nzbWalkID || lines[0].State != Unheld ||
|
||||
!strings.Contains(lines[0].Says, "ended as nothing-to-move") || !strings.Contains(lines[0].Says, IDOf(media, headNzb)) ||
|
||||
!strings.Contains(lines[0].Says, "nzbget") {
|
||||
t.Fatalf("the walk is said as %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// **The status names each held delivery, why, and the verb that releases it** (novox/hq issue 529): "held: 1"
|
||||
// alone pointed a reader nowhere.
|
||||
func TestTheStatusNamesAHeldDeliveryAndWhy(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atNzb)
|
||||
p := pr(media, 22, headNzb, "adr-0307-state-history", "modules/nzbgeek/module.json")
|
||||
w.h.PullUpdated(p)
|
||||
w.h.PullMerged(mergedAt(p, mergeNzb, atNzb)) // merged before any verdict: held for a person
|
||||
w.settleAll()
|
||||
id := IDOf(media, headNzb)
|
||||
d := w.delivery(id)
|
||||
if d.State != Held {
|
||||
t.Fatalf("#22 is %s", d.State)
|
||||
}
|
||||
held, _ := w.h.Status("listening")["held"].([]string)
|
||||
if len(held) != 1 || !strings.Contains(held[0], id) || !strings.Contains(held[0], d.HeldWhy) ||
|
||||
!strings.Contains(held[0], "mesh-delivery.release") {
|
||||
t.Fatalf("the status says held %q", held)
|
||||
}
|
||||
}
|
||||
|
||||
// **A walk waiting for this owner's word is told why** (novox/hq issue 529): asked with `walks`, `stalled` says of
|
||||
// each walk waiting for its word what holds it — a held delivery, naming it, its reason and `mesh-delivery.release`,
|
||||
// or the deliveries not at their turn — so the controller's `plans` points at the person's decision, not at a hand
|
||||
// start.
|
||||
func TestAWaitingWalkIsToldWhatHoldsIt(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atDu.Add(5*time.Second))
|
||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
||||
w.h.PullUpdated(cc)
|
||||
w.h.PullUpdated(du)
|
||||
w.h.Checked(verdictMoving(cat, 101, headCC, "claude-code"))
|
||||
w.h.PullMerged(mergedAt(cc, mergeCC, atCC))
|
||||
w.h.PullMerged(mergedAt(du, mergeDu, atDu)) // unchecked: held for a person
|
||||
walk := catWalk("plan-9", mergeDu, atDu.Add(5*time.Second), "claude-code", "dunst")
|
||||
walk.Delivery.Merges = []WalkMerge{{Repository: cat, Commit: mergeCC, Carried: mergeDu}, {Repository: cat, Commit: mergeDu}}
|
||||
w.h.WalkMoved(w.ctl.put(walk))
|
||||
w.settleAll()
|
||||
idDu := IDOf(cat, headDu)
|
||||
lines := w.h.WalksWaiting()
|
||||
if len(lines) != 1 || lines[0].Walk != "plan-9" || lines[0].State != Held || lines[0].ID != idDu ||
|
||||
!strings.Contains(lines[0].Says, w.delivery(idDu).HeldWhy) || !strings.Contains(lines[0].Says, "mesh-delivery.release") {
|
||||
t.Fatalf("the waiting walk is said as %+v", lines)
|
||||
}
|
||||
// Not by default: `stalled` stays what the self-check raises conditions for.
|
||||
for _, l := range w.h.Stalled() {
|
||||
if l.Walk == "plan-9" {
|
||||
t.Fatalf("stalled lists the waiting walk without being asked: %+v", l)
|
||||
}
|
||||
}
|
||||
// Released, its word is asked: nothing holds it any more.
|
||||
if _, err := w.h.Release(idDu, "checked by hand", "jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.h.WalkMoved(w.ctl.walk("plan-9"))
|
||||
w.settleAll()
|
||||
if lines := w.h.WalksWaiting(); len(lines) != 0 {
|
||||
t.Fatalf("a walk let go is still said waiting: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// A walk waiting while a delivery it carries is not at its turn says so, naming the delivery — what was said only
|
||||
// in this owner's journal (novox/hq issue 529, note 2).
|
||||
func TestAWaitingWalkSaysWhichDeliveryIsNotAtItsTurn(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atNzb)
|
||||
w.h.mu.Lock()
|
||||
w.h.waitsWhy = map[string]string{"plan-7": "novox/x@1 (published) carried by it not at its turn"}
|
||||
w.h.walks["plan-7"] = seenWalk{walk: aWalk("plan-7", "novox/x", "1111", true), at: atNzb}
|
||||
w.h.mu.Unlock()
|
||||
lines := w.h.WalksWaiting()
|
||||
if len(lines) != 1 || lines[0].Walk != "plan-7" || !strings.Contains(lines[0].Says, "not at its turn") {
|
||||
t.Fatalf("the waiting walk is said as %+v", lines)
|
||||
}
|
||||
}
|
||||
@@ -268,8 +268,17 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
"and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||
Run: func(map[string]any) (any, error) { return TableText(), nil }},
|
||||
{Name: seat + "stalled",
|
||||
Description: "Every delivery held past its state's bound, with the transition the table lets healer H2 take.",
|
||||
Run: func(map[string]any) (any, error) { return h.Stalled(), nil }},
|
||||
Description: "Every delivery held past its state's bound, with the transition the table lets healer H2 take. " +
|
||||
"With walks: true, instead each walk waiting for this owner's word and what holds it — a held delivery, " +
|
||||
"its reason and the verb that releases it, or the deliveries not at their turn (novox/hq issue 529).",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"walks": map[string]any{"type": "boolean", "description": "the waiting walks and what holds each, instead"}}},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if walks, _ := a["walks"].(bool); walks {
|
||||
return h.WalksWaiting(), nil
|
||||
}
|
||||
return h.Stalled(), nil
|
||||
}},
|
||||
{Name: seat + "recheck",
|
||||
Description: "Check a rejected or ready delivery again: its merge check statuses put back to pending on the forge, " +
|
||||
"so nothing merges on the old verdict; proposed again, and its check asked. With why.",
|
||||
|
||||
@@ -635,6 +635,18 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
||||
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
|
||||
"seat-on-record": h.held}
|
||||
// Each held delivery by name, with why and the verb that releases it (novox/hq issue 529): a count alone
|
||||
// pointed a reader at nothing.
|
||||
var held []string
|
||||
for _, d := range h.deliveries {
|
||||
if d.State == Held {
|
||||
held = append(held, HeldLine(d))
|
||||
}
|
||||
}
|
||||
if len(held) > 0 {
|
||||
sort.Strings(held)
|
||||
out["held"] = held
|
||||
}
|
||||
if !h.reconciled.IsZero() {
|
||||
out["walks-read"] = h.reconciled.Format(time.RFC3339)
|
||||
}
|
||||
@@ -743,3 +755,17 @@ func withoutKinds(owed []Effect, kinds ...string) []Effect {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeldLine says a held delivery in one line: which, since when, why, its walk when one waits, and the verb that
|
||||
// releases it (novox/hq issue 529).
|
||||
func HeldLine(d *Delivery) string {
|
||||
why := d.HeldWhy
|
||||
if why == "" {
|
||||
why = lastWhyOf(d)
|
||||
}
|
||||
s := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), why)
|
||||
if d.Walk != nil && d.Walk.Waits {
|
||||
s += "; its walk " + d.Walk.ID + " waits for it"
|
||||
}
|
||||
return s + " — a person decides: `mesh-delivery.release` (id " + d.ID + ", why) lets it go, `mesh-delivery.stop` ends it"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user