mesh-delivery: give an unanswered line's checks as data, so the controller says their times in the operator's zone (novox/hq issue 443)
mesh/merge-gate pass: builds mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group fix/443-a-stalled-lines-times-are-local delivered: every member is delivered

This commit is contained in:
2026-10-11 03:10:22 +02:00
parent ebd940fcd0
commit 30788c46a1
3 changed files with 142 additions and 5 deletions
@@ -0,0 +1,66 @@
package main
import (
"encoding/json"
"slices"
"strings"
"testing"
"time"
)
// **An unanswered line gives the checks it waits on as data** (novox/hq issue 443): each check's context, its state
// and, for a pending one, since when in RFC 3339 — so the controller says the time in the operator's own zone. A time
// inside a finished sentence cannot be said again in another zone, and the operator read "since
// 2026-10-11T00:11:39Z". The finished words stay beside the data, for a controller that does not read it yet.
func TestAnUnansweredLineGivesItsChecksAsData(t *testing.T) {
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC))
w.forge.open = openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T02:11:39+02:00", 1))
w.h.SeeOpenPulls()
w.later(Bounds[Proposed].For + time.Minute)
lines := w.h.Stalled()
if len(lines) != 1 {
t.Fatalf("the head is not said: %+v", lines)
}
want := []WaitingCheck{{Context: "mesh/merge-gate", State: CheckPending, Since: "2026-10-11T00:11:39Z"},
{Context: "mesh/repo-check", State: CheckNeverSet}}
if !slices.Equal(lines[0].Checks, want) {
t.Fatalf("the line's checks are %+v, not %+v", lines[0].Checks, want)
}
if words := []string{"mesh/merge-gate pending since 2026-10-11T00:11:39Z", "mesh/repo-check never set"}; !slices.Equal(lines[0].Waiting, words) {
t.Fatalf("the finished words a controller of before reads are gone: %q", lines[0].Waiting)
}
raw, _ := json.Marshal(lines[0])
t.Logf("the line as the controller reads it: %s", raw)
if !strings.Contains(string(raw), `"checks":[{"context":"mesh/merge-gate","state":"pending","since":"2026-10-11T00:11:39Z"},{"context":"mesh/repo-check","state":"never-set"}]`) {
t.Fatalf("the line's checks are not in the shape the controller reads: %s", raw)
}
}
// The data and the words never disagree: for every head the words are made from, the checks give the same contexts
// in the same order, and a pending time the forge did not say, or said unreadably, is given as no time at all.
func TestAnUnansweredLinesChecksAgreeWithItsWords(t *testing.T) {
for _, tc := range []struct {
name, at string
since string
}{{"a time in UTC", "2026-10-11T00:11:39Z", "2026-10-11T00:11:39Z"},
{"a time with an offset", "2026-10-11T02:11:39+02:00", "2026-10-11T00:11:39Z"},
{"no time", "", ""},
{"an unreadable time", "yesterday", ""}} {
t.Run(tc.name, func(t *testing.T) {
p := openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", tc.at, 1))[0]
words, _, _ := p.unanswered()
checks := p.waitingChecks()
if len(words) != len(checks) {
t.Fatalf("words %q, checks %+v", words, checks)
}
for i, c := range checks {
if !strings.HasPrefix(words[i], c.Context+" ") {
t.Fatalf("word %q is not of check %+v", words[i], c)
}
}
if checks[0].State != CheckPending || checks[0].Since != tc.since {
t.Fatalf("the pending check is %+v, not since %q", checks[0], tc.since)
}
})
}
}
@@ -175,10 +175,14 @@ type StalledLine struct {
// never set", "mesh/merge-gate pending since <UTC time>" — so the controller's words name them without reading
// Says (novox/hq issue 438).
Waiting []string `json:"waiting,omitempty"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
// Checks are the same merge checks as data — context, state, since when — so the controller says their times in
// the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays
// beside them for a controller that does not read them yet.
Checks []WaitingCheck `json:"checks,omitempty"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
}
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
@@ -210,7 +214,7 @@ func (h *Holder) Stalled() []StalledLine {
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
Bound: b.For.String(), H2: h2, Says: b.Says})
}
out = append(out, h.uncheckedLines(now)...)
out = append(out, h.withChecks(h.uncheckedLines(now))...)
out = append(out, h.orphanLines(now)...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
@@ -0,0 +1,67 @@
package main
import (
"slices"
"time"
)
// The merge checks an unanswered line waits on, as data (novox/hq issue 443). The line's Waiting says them as finished
// words, with a pending check's time in UTC; the operator reads the controller's words, which give every time in the
// operator's own zone, and the controller cannot say a time again inside a sentence it did not write. So each check
// is given too as its context, its state and since when, and the controller words it.
// WaitingCheck is one merge check a stalled line of the state Unanswered waits on.
type WaitingCheck struct {
Context string `json:"context"`
// State is CheckPending or CheckNeverSet.
State string `json:"state"`
// Since is when a pending check was set so, in RFC 3339 and UTC; empty when the forge did not say, or said what
// cannot be read — never a time made up.
Since string `json:"since,omitempty"`
}
const (
CheckPending = "pending"
CheckNeverSet = "never-set"
)
// waitingChecks are the checks unanswered names, in its order and by its rule, as data.
func (p OpenPull) waitingChecks() []WaitingCheck {
if p.HeadSHA == "" {
return nil
}
started := slices.ContainsFunc(mergeChecks, func(c string) bool {
return slices.Contains(p.Statuses, c) || p.Checks[c].State != ""
})
var out []WaitingCheck
for _, c := range mergeChecks {
if !slices.Contains(p.Required, c) {
continue
}
got, ok := p.Checks[c]
switch {
case p.Checks == nil && started:
// Read as checked, as unanswered does: nothing says whether the check it has answered.
case !ok || got.State == "":
out = append(out, WaitingCheck{Context: c, State: CheckNeverSet})
case got.State == "pending":
w := WaitingCheck{Context: c, State: CheckPending}
if t, err := time.Parse(time.RFC3339, got.UpdatedAt); err == nil {
w.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, w)
}
}
return out
}
// withChecks gives each unanswered line the checks it waits on as data, from the head it was said of; under the lock.
// It reads every line of the state, whichever rule said it, so a line said for another reason carries them too.
func (h *Holder) withChecks(lines []StalledLine) []StalledLine {
for i, l := range lines {
if u, ok := h.unchecked[l.ID]; ok && l.State == Unanswered && len(l.Waiting) > 0 {
lines[i].Checks = u.pull.waitingChecks()
}
}
return lines
}