Say where a pending merge check is: queued since when, then building on which machine (hq issue 446)
mesh/merge-gate pass: builds gitea, 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 superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea, 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 superseded: a newer head of the same pull request
The forge's holder set mesh/merge-gate pending as "the mesh is mapping this head onto its module graph" and nothing changed it until the verdict, so a ten-minute controller check read as a stalled forge (issue 423). The holder now says the head waits in the build queue since the local time it was announced; mesh-delivery follows the build seat's started event and says the machine building it and since when, never over a verdict the forge holds.
This commit is contained in:
@@ -27,8 +27,8 @@
|
||||
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
type RepoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, pendingWords, statusesFor, type Announced,
|
||||
type Checked, type RepoCheckFacts, reposToLook } from "./pulls.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||
@@ -274,10 +274,11 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
});
|
||||
// Said, so an operator knows the mesh was asked to check it.
|
||||
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing.
|
||||
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
|
||||
// where it is: in the build queue from now, until mesh-delivery says a build machine took it (issue 446).
|
||||
await client
|
||||
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
|
||||
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
|
||||
state: "pending", context: CHECK_CONTEXT, description: pendingWords(new Date()),
|
||||
})
|
||||
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,18 @@ export const CHECK_CONTEXT = "mesh/merge-gate";
|
||||
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
|
||||
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
|
||||
|
||||
/**
|
||||
* The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when,
|
||||
* in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole
|
||||
* check — the few seconds the controller maps the head and then the queue wait and the build — and readers took the
|
||||
* forge for lagging (issue 423). The controller maps and queues the check in seconds, so from the announcement it waits
|
||||
* in the build queue; mesh-delivery says "building on <machine> since …" when a build machine takes it.
|
||||
*/
|
||||
export function pendingWords(announced: Date): string {
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
return `waiting in the build queue since ${two(announced.getHours())}:${two(announced.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** One layer of a check, judged. */
|
||||
export interface Layer {
|
||||
verdict: string;
|
||||
|
||||
@@ -146,3 +146,11 @@ test("a change plan is the gate's result: said on the status and, when it builds
|
||||
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
|
||||
assert.equal(commentFor(nothing), null);
|
||||
});
|
||||
|
||||
// **A pending check says where it is** (novox/hq issue 446): announced, it waits in the build queue, since the time the
|
||||
// operator reads — not "mapping this head onto its module graph" for the ten minutes of a controller check.
|
||||
test("a head announced is pending in the build queue, since the local time it was announced", async () => {
|
||||
const { pendingWords } = await import("../pulls.ts");
|
||||
assert.equal(pendingWords(new Date(2026, 9, 11, 3, 8, 50)), "waiting in the build queue since 03:08");
|
||||
assert.equal(pendingWords(new Date(2026, 9, 11, 14, 0, 0)), "waiting in the build queue since 14:00");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A pending check says where it is** (novox/hq issue 446). The forge's holder sets a head's mesh/merge-gate pending
|
||||
// as it announces the head, and until the verdict nothing said anything else: a controller check reads the same
|
||||
// words for its whole ten minutes — the wait in the build queue and five of tests — and readers took the forge for
|
||||
// lagging (issue 423). The build seat says when a machine takes an ask (`started`, novox/hq ADR 0157), and this owner
|
||||
// follows every head's delivery, so it says that moment on the head's pending status: building on which machine,
|
||||
// since when. One write per check, on the seat's own event — the forge is never polled for it.
|
||||
//
|
||||
// What the wait before is (behind how many asks) only the controller's build queue knows, and its `queue` verb answers
|
||||
// it as text alone: the forge's holder says the wait from when it announced the head, without the count.
|
||||
|
||||
// BuildSeat is the seat whose holders take the build queue's asks, the mesh's merge checks among them (novox/hq ADR
|
||||
// 0190).
|
||||
const BuildSeat = "node-build-agent"
|
||||
|
||||
// BuildStarted is the build seat's `started`: a machine took an ask (mesh-controller's link.BuildStart).
|
||||
type BuildStarted struct {
|
||||
ID string `json:"id"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
On string `json:"on"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// clockSlack is how far another machine's clock may stand behind this one's when a start is weighed against when
|
||||
// the head was announced.
|
||||
const clockSlack = time.Minute
|
||||
|
||||
// buildingWords is the pending status of a check a machine is running: local time, as the operator reads it.
|
||||
func buildingWords(on string, at time.Time) string {
|
||||
return fmt.Sprintf("building on %s since %s", on, at.Local().Format("15:04"))
|
||||
}
|
||||
|
||||
// clonedAs says whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is this owner/repository.
|
||||
func clonedAs(url, repository string) bool {
|
||||
url = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(url), ".git"))
|
||||
return url == repository || strings.HasSuffix(url, "/"+repository)
|
||||
}
|
||||
|
||||
// Started takes a machine taking an ask. Only a head still waiting for its own check is told: an ask of another
|
||||
// commit or repository, a build of a branch, and a start from before the head was announced — the stream's history
|
||||
// replayed — say nothing; so does a head whose verdict this owner already holds.
|
||||
func (h *Holder) Started(b BuildStarted) {
|
||||
at, err := time.Parse(time.RFC3339Nano, b.At)
|
||||
if err != nil || b.On == "" || len(b.Ref) < 40 || strings.Trim(strings.ToLower(b.Ref), "0123456789abcdef") != "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, d := range h.deliveries {
|
||||
if !sameCommit(d.Commit, b.Ref) || !clonedAs(b.Repository, d.Repository) || d.State != Proposed ||
|
||||
d.Check != nil || at.Before(d.Created.Add(-clockSlack)) {
|
||||
continue
|
||||
}
|
||||
d.owe(Effect{Kind: EffectStatus, Commit: d.Commit, Context: ctxGate, State: "pending",
|
||||
Line: buildingWords(b.On, at), Since: h.Now()})
|
||||
h.keep(d)
|
||||
h.Logf("[mesh-delivery] %s: its check is building on %s since %s (%s)", d.ID, b.On, b.At, b.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// startedOf reads the seat's event; one that is not readable is said and dropped, like any event.
|
||||
func startedOf(body json.RawMessage) (BuildStarted, error) {
|
||||
var b BuildStarted
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return BuildStarted{}, fmt.Errorf("a build's start is not readable: %v", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// dropPendingGate forgets the pending words owed on a head whose verdict came: the forge's holder sets the verdict,
|
||||
// and nothing of this owner's may say pending after it.
|
||||
func (d *Delivery) dropPendingGate() {
|
||||
kept := d.Owed[:0]
|
||||
for _, e := range d.Owed {
|
||||
if e.Kind == EffectStatus && e.Context == ctxGate && e.State == "pending" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, e)
|
||||
}
|
||||
d.Owed = kept
|
||||
}
|
||||
|
||||
// gateStillPending says whether the forge still holds the head's mesh/merge-gate pending — or holds none — so the
|
||||
// pending words are said only before the verdict, which the forge's holder may have set before this owner heard
|
||||
// it. A forge that cannot be read is an error: the words are tried again.
|
||||
func (h *Holder) gateStillPending(owner, repo, commit string) (bool, error) {
|
||||
fc, err := h.Forge.Statuses(owner, repo, 0, commit, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, s := range fc.Statuses {
|
||||
if s.Context == ctxGate {
|
||||
return s.State == "pending", nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -349,6 +349,17 @@ func (h *Holder) do(e Effect, repo [2]string, number int, body, url string, grou
|
||||
owner, name, _ := strings.Cut(ownerRepo, "/")
|
||||
return h.Forge.Status(owner, name, e.Commit, e.Context, e.State, e.Line, page)
|
||||
}
|
||||
if e.Context == ctxGate && e.State == "pending" {
|
||||
// Where a pending check is (novox/hq issue 446), said only while the forge holds no verdict: one set
|
||||
// by the forge's holder before this owner heard it is never said over.
|
||||
pending, err := h.gateStillPending(repo[0], repo[1], e.Commit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pending {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return h.Forge.Status(repo[0], repo[1], e.Commit, e.Context, e.State, e.Line, url)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -541,6 +541,9 @@ func (h *Holder) Checked(c CheckedEvent) {
|
||||
repository := strings.ToLower(c.Owner + "/" + c.Repo)
|
||||
id := IDOf(repository, c.Commit)
|
||||
d := h.deliveries[id]
|
||||
if d != nil {
|
||||
d.dropPendingGate() // the verdict is the forge's holder's to set now (novox/hq issue 446)
|
||||
}
|
||||
if d == nil {
|
||||
if c.Number == 0 {
|
||||
return // a check of no pull request: nothing to deliver
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The build seat's `started` as a build machine said it for novox/hq#309's merge check on 2026-10-11 (novox/hq ADR
|
||||
// 0157; its log, read with the controller's `builds --log`, opens at 03:09:01 on shanks): the ask's id, the
|
||||
// repository as the mesh clones it, the head checked, the machine and when — the shape mesh-controller's
|
||||
// link.BuildStart publishes, with only the forge's address replaced.
|
||||
const startedOnShanks = `{"id": "build-1791680941048622697", "repository": "http://127.0.0.1:20000/novox/hq.git",
|
||||
"ref": "a6b08de8507c1703528277e5cefc80548dbc9bb3", "on": "shanks", "at": "2026-10-11T01:09:01.204319Z"}`
|
||||
|
||||
const head309 = "a6b08de8507c1703528277e5cefc80548dbc9bb3"
|
||||
|
||||
// heard hands the holder one event the way the runtime does: its key and its body.
|
||||
func heard(t *testing.T, h *Holder, key, body string) {
|
||||
t.Helper()
|
||||
if err := handle(h, stdio.Envelope{Key: key, Body: json.RawMessage(body)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// inBrussels is the operator's clock for a test: the words say local times, and the machine that runs this owner
|
||||
// keeps the operator's.
|
||||
func inBrussels(t *testing.T) {
|
||||
t.Helper()
|
||||
was := time.Local
|
||||
time.Local = time.FixedZone("CEST", 2*60*60)
|
||||
t.Cleanup(func() { time.Local = was })
|
||||
}
|
||||
|
||||
// **A pending check says where it is** (novox/hq issue 446). The forge's holder sets mesh/merge-gate pending as it
|
||||
// announces a head, and nothing changed those words again until the verdict: a controller check reads the same for
|
||||
// its whole ten minutes — the queue wait and five of tests — and readers reported the forge as lagging (issue 423).
|
||||
// The build seat says when a machine takes the ask (`started`); this owner, which follows the head's delivery,
|
||||
// says it on the head's pending status: building on which machine, since when.
|
||||
func TestAPendingCheckSaysWhichMachineBuildsItSinceWhen(t *testing.T) {
|
||||
inBrussels(t)
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 1, 8, 50, 0, time.UTC))
|
||||
w.h.PullUpdated(pr("novox/hq", 309, head309, "decisions/0292"))
|
||||
// As the forge's holder set it when it announced the head.
|
||||
w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"] = "pending waiting in the build queue since 03:08 → "
|
||||
w.h.Flush()
|
||||
|
||||
w.later(11 * time.Second)
|
||||
heard(t, w.h, "node-build-agent.started", startedOnShanks)
|
||||
w.h.Flush()
|
||||
got := w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"]
|
||||
if !strings.HasPrefix(got, "pending building on shanks since 03:09 → ") {
|
||||
t.Fatalf("the pending check does not say where it is: mesh/merge-gate %q", got)
|
||||
}
|
||||
|
||||
// The verdict, set by the forge's holder: nothing of this owner's says pending over it again.
|
||||
w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"] = "success pass: the change touches no module of the mesh's graph → "
|
||||
heard(t, w.h, "node-build-agent.started", startedOnShanks) // heard again, from the stream's history
|
||||
w.h.Checked(verdict("novox/hq", 309, head309, "pass"))
|
||||
w.h.Flush()
|
||||
if got := w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"]; !strings.HasPrefix(got, "success ") {
|
||||
t.Fatalf("a verdict was said over with pending: mesh/merge-gate %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine taking an ask says nothing over a verdict the forge already holds, though this owner has not heard
|
||||
// that verdict yet: the forge's status is read before pending is said again.
|
||||
func TestAStartedHeardAfterTheVerdictWasSetSaysNothingOverIt(t *testing.T) {
|
||||
inBrussels(t)
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 1, 8, 50, 0, time.UTC))
|
||||
w.h.PullUpdated(pr("novox/hq", 309, head309, "decisions/0292"))
|
||||
w.h.Flush()
|
||||
heard(t, w.h, "node-build-agent.started", startedOnShanks)
|
||||
// The verdict lands on the forge between the started and this owner's write.
|
||||
w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"] = "failure fail: 0 of 4 compose → "
|
||||
w.h.Flush()
|
||||
if got := w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"]; !strings.HasPrefix(got, "failure ") {
|
||||
t.Fatalf("pending was said over the verdict the forge held: mesh/merge-gate %q", got)
|
||||
}
|
||||
for _, e := range w.delivery(IDOf("novox/hq", head309)).Owed {
|
||||
if e.Context == ctxGate {
|
||||
t.Fatalf("the pending words are still owed after the forge held a verdict: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A started of another head, another repository, or one before this head was announced (the stream's history, a
|
||||
// build of the same commit long ago) says nothing on this head.
|
||||
func TestAStartedOfAnotherAskSaysNothingOnThisHead(t *testing.T) {
|
||||
inBrussels(t)
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 1, 8, 50, 0, time.UTC))
|
||||
w.h.PullUpdated(pr("novox/hq", 309, head309, "decisions/0292"))
|
||||
w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"] = "pending waiting in the build queue since 03:08 → "
|
||||
w.h.Flush()
|
||||
others := []string{
|
||||
strings.Replace(startedOnShanks, head309, "d5cf3b42fec6f5bce99dc6dd37754cee2ce96246", 1),
|
||||
strings.Replace(startedOnShanks, "/novox/hq.git", "/novox/mesh-controller.git", 1),
|
||||
strings.Replace(startedOnShanks, "2026-10-11T01:09:01.204319Z", "2026-10-10T22:00:00Z", 1),
|
||||
`{"id": "build-1", "repository": "http://127.0.0.1:20000/novox/hq.git", "ref": "main", "on": "g14",
|
||||
"at": "2026-10-11T01:09:01Z"}`,
|
||||
`not a started`,
|
||||
}
|
||||
for _, body := range others {
|
||||
heard(t, w.h, "node-build-agent.started", body)
|
||||
}
|
||||
w.h.Flush()
|
||||
if got := w.forge.statuses["novox/hq@"+head309+" mesh/merge-gate"]; got != "pending waiting in the build queue since 03:08 → " {
|
||||
t.Fatalf("another ask's start was said on this head: mesh/merge-gate %q", got)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,10 @@ type listening struct {
|
||||
func (l *listening) set(s string) { l.mu.Lock(); l.now = s; l.mu.Unlock() }
|
||||
func (l *listening) get() string { l.mu.Lock(); defer l.mu.Unlock(); return l.now }
|
||||
|
||||
// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks.
|
||||
// The events this owner follows: the forge's pull requests, the controller's verdicts and walks, and a build machine
|
||||
// taking an ask — said on a pending check's head (novox/hq issue 446).
|
||||
var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed",
|
||||
ControllerSeat + ".checked", ControllerSeat + ".plan-moved"}
|
||||
ControllerSeat + ".checked", ControllerSeat + ".plan-moved", BuildSeat + ".started"}
|
||||
|
||||
func main() {
|
||||
h := &Holder{
|
||||
@@ -133,6 +134,13 @@ func handle(h *Holder, e stdio.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
h.WalkMoved(w)
|
||||
case BuildSeat + ".started":
|
||||
b, err := startedOf(e.Body)
|
||||
if err != nil {
|
||||
h.Refuse(err)
|
||||
return nil
|
||||
}
|
||||
h.Started(b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"gitea.pull.merged",
|
||||
"gitea.pull.closed",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved"
|
||||
"mesh-controller.plan-moved",
|
||||
"node-build-agent.started"
|
||||
],
|
||||
"emits": [
|
||||
"transition",
|
||||
|
||||
Reference in New Issue
Block a user