From 4254d173221b2c4b1ced827ae96232fb0db3a1e9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 03:14:06 +0200 Subject: [PATCH 1/2] Say where a pending merge check is: queued since when, then building on which machine (hq issue 446) 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. --- modules/gitea/index.ts | 9 +- modules/gitea/pulls.ts | 12 ++ modules/gitea/test/pulls.test.ts | 8 ++ .../cmd/mesh-delivery/building.go | 107 +++++++++++++++++ .../cmd/mesh-delivery/effects.go | 11 ++ .../mesh-delivery/cmd/mesh-delivery/holder.go | 3 + .../cmd/mesh-delivery/issue446_test.go | 113 ++++++++++++++++++ .../mesh-delivery/cmd/mesh-delivery/main.go | 12 +- modules/mesh-delivery/module.json | 3 +- 9 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/building.go create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 560198f7..840696a7 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -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 { }); // 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}`)); } diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 3f45f08f..4e0ec815 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -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 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; diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index 8e228a85..e1396125 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -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"); +}); diff --git a/modules/mesh-delivery/cmd/mesh-delivery/building.go b/modules/mesh-delivery/cmd/mesh-delivery/building.go new file mode 100644 index 00000000..16749b66 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/building.go @@ -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 +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/effects.go b/modules/mesh-delivery/cmd/mesh-delivery/effects.go index 02d6694b..cb60f204 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/effects.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/effects.go @@ -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 diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery/cmd/mesh-delivery/holder.go index 360746eb..0d5fa211 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder.go @@ -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 diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go new file mode 100644 index 00000000..9729c714 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go @@ -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) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery/cmd/mesh-delivery/main.go index 6fe80a48..42954829 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/main.go @@ -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 } diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery/module.json index 1215633c..781161a3 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery/module.json @@ -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", From 1cf56688c1d597b4f54617643c94dab843f66315 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 03:23:52 +0200 Subject: [PATCH 2/2] Make the gitea module the one writer of mesh/merge-gate, so a verdict always lands last (hq issue 446) mesh-delivery read the gate as pending and then wrote "building on", and the gitea module could write the verdict in between: the forge keeps the newest status, so a pass read pending and a failure hid under "building". The gitea module now hears the build seat's started itself and puts every write of a head's gate on one chain, marking a verdict the moment it is heard; a start from before the pending words were last set is not the current check's. --- modules/gitea/gate.ts | 139 +++++++++++++++ modules/gitea/index.ts | 49 +++++- modules/gitea/module.json | 3 +- modules/gitea/test/gate.test.ts | 164 ++++++++++++++++++ .../cmd/mesh-delivery/building.go | 107 ------------ .../cmd/mesh-delivery/effects.go | 11 -- .../mesh-delivery/cmd/mesh-delivery/holder.go | 3 - .../cmd/mesh-delivery/issue446_test.go | 113 ------------ .../mesh-delivery/cmd/mesh-delivery/main.go | 12 +- modules/mesh-delivery/module.json | 3 +- 10 files changed, 348 insertions(+), 256 deletions(-) create mode 100644 modules/gitea/gate.ts create mode 100644 modules/gitea/test/gate.test.ts delete mode 100644 modules/mesh-delivery/cmd/mesh-delivery/building.go delete mode 100644 modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts new file mode 100644 index 00000000..b636a422 --- /dev/null +++ b/modules/gitea/gate.ts @@ -0,0 +1,139 @@ +// **One writer of a head's mesh/merge-gate, and the verdict lands last** (novox/hq issue 446). +// +// A pending check says where it is: "waiting in the build queue since …" when the head is announced, "building on +// since …" when a build machine takes the check — the build seat's `started` (novox/hq ADR 0157) — and the +// verdict when the controller says `checked`. The forge keeps only the newest status of a context, so a pending word +// written after the verdict hides it: a pass reads pending and the merge waits for ever; a failure hides under +// "building". Two writers cannot order their writes, and a writer that reads the forge before writing races the other +// between its read and its write. So this module is the one writer of mesh/merge-gate, and every write of one head's +// gate goes through one chain: a verdict is marked the moment it is heard, before it waits its turn, and a "building" +// still waiting behind or taking its turn after that mark is dropped, never written. + +import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; +import type { CommitStatus } from "./client.js"; + +/** The build seat's `started`, as mesh-controller's link.BuildStart publishes it. */ +export interface BuildStarted { + id?: string; + repository?: string; + path?: string; + ref?: string; + on?: string; + at?: string; +} + +/** What the gate writer needs of the forge: a status set, and a head's gate as the forge holds it. */ +export interface GateForge { + setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; + /** The head's mesh/merge-gate, newest: its state and when it was set; undefined when it has none. */ + gateOf(owner: string, repo: string, sha: string): Promise<{ state: string; updated_at?: string } | undefined>; +} + +/** How far a build machine's clock may stand behind this one's when a start is weighed against the pending words. */ +const CLOCK_SLACK_MS = 60_000; + +/** The words of a check a machine is running, in the time the operator reads. */ +export function buildingWords(on: string, at: Date): string { + const two = (n: number) => String(n).padStart(2, "0"); + return `building on ${on} since ${two(at.getHours())}:${two(at.getMinutes())}`; +} + +/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */ +export function clonedAs(url: string | undefined, owner: string, repo: string): boolean { + const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, ""); + const full = `${owner}/${repo}`.toLowerCase(); + return u === full || u.endsWith("/" + full); +} + +interface Head { + /** When the pending words were last set by this writer; undefined for a head this process did not announce. */ + pendingAt?: number; + /** A verdict was heard for the head: nothing pending is written after it. */ + verdict: boolean; + /** The chain the head's writes take their turns on. */ + chain: Promise; +} + +export class GateWriter { + private heads = new Map(); + private forge: GateForge; + private now: () => Date; + + constructor(forge: GateForge, now: () => Date = () => new Date()) { + this.forge = forge; + this.now = now; + } + + private head(owner: string, repo: string, sha: string): Head { + const key = `${owner}/${repo}@${sha}`.toLowerCase(); + let h = this.heads.get(key); + if (!h) { + h = { verdict: false, chain: Promise.resolve() }; + this.heads.set(key, h); + // Kept for the heads still in play; the oldest go first, past what any forge holds open at once. + if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string); + } + return h; + } + + /** One turn on the head's chain; a turn that fails never stops the next. Its failure is the caller's. */ + private turn(h: Head, fn: () => Promise): Promise { + const run = h.chain.then(fn); + h.chain = run.catch(() => {}); + return run; + } + + /** A turn whose failure is said here: a pending word not written is not worth the event again. */ + private said(h: Head, what: string, fn: () => Promise): Promise { + return this.turn(h, fn).catch((err) => { + console.error(`[gitea] ${what} — ${err instanceof Error ? err.message : err}`); + }); + } + + /** The head announced: it waits in the build queue from now. A new check of the head, so no verdict yet. */ + pending(owner: string, repo: string, sha: string): Promise { + const h = this.head(owner, repo, sha); + const at = this.now(); + h.pendingAt = at.getTime(); + h.verdict = false; + return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () => + this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) })); + } + + /** + * A build machine took an ask of this head: said while no verdict was heard, and only for the check now pending — + * a start from before the pending words were set (a run before a recheck, the stream's history) is not this check's. + * A head this process did not announce (it restarted since) is weighed against the forge's own gate: said only while + * that is pending, and not for a start older than it. + */ + started(owner: string, repo: string, sha: string, b: BuildStarted): Promise { + const at = b.at ? new Date(b.at) : new Date(NaN); + if (!b.on || Number.isNaN(at.getTime())) return Promise.resolve(false); + const h = this.head(owner, repo, sha); + let said = false; + return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say where its check is`, async () => { + if (h.verdict) return; + let since = h.pendingAt; + if (since === undefined) { + const gate = await this.forge.gateOf(owner, repo, sha); + if (gate && gate.state !== "pending") return; + since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined; + } + if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return; + if (h.verdict) return; // heard while the forge was read + await this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, + description: buildingWords(b.on as string, at) }); + said = true; + }).then(() => said); + } + + /** + * The verdict: marked at once, so no pending word is written after it, then written in its turn — last. A write + * that fails fails the caller, so the event is offered again. + */ + verdict(owner: string, repo: string, sha: string, write: () => Promise): Promise { + const h = this.head(owner, repo, sha); + h.verdict = true; + return this.turn(h, write); + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 840696a7..96d05528 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -14,6 +14,9 @@ // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment // saying why when the gate is not a pass or the repository's own check failed. // +// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of, +// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate. +// // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // @@ -27,8 +30,9 @@ import { emit, on } from "@novox/mesh-sdk/events"; import { GiteaClient, movedSince } from "./client.js"; -import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, pendingWords, statusesFor, type Announced, - type Checked, type RepoCheckFacts, reposToLook } from "./pulls.js"; +import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, + type RepoCheckFacts, reposToLook } from "./pulls.js"; +import { GateWriter, clonedAs, type BuildStarted } from "./gate.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 @@ -275,12 +279,8 @@ async function pollPulls(client: GiteaClient): Promise { // 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 — 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: pendingWords(new Date()), - }) - .catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`)); + // where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). + await gates.pending(repo.owner, repo.name, String(pull.head_sha)); } heads[key] = String(pull.head_sha); changed = true; @@ -338,6 +338,31 @@ async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | un return { defined, required }; } +// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, +// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last +// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. +const gates = new GateWriter({ + setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), + gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT], +}); + +// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when +// the head is an open pull request's this module announced. Any other build — a branch, a merge's — says nothing. +async function buildStarted(event: { body: unknown }): Promise { + const b = (event.body ?? {}) as BuildStarted; + const ref = String(b.ref ?? "").toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(ref)) return; + for (const [key, sha] of Object.entries(heads)) { + if (sha.toLowerCase() !== ref) continue; + const [owner, rest] = key.split("/"); + const repo = rest?.split("#")[0] ?? ""; + if (owner && repo && clonedAs(b.repository, owner, repo)) { + await gates.started(owner, repo, sha, b); + return; + } + } +} + if (gitea) { const client = gitea; // A poll that fails says so once, not once a minute: the same reason repeating (the forge not up @@ -364,6 +389,12 @@ if (gitea) { tick(() => pollRepos(client), 60_000); tick(() => pollMerged(client), 30_000); tick(() => pollPulls(client), 30_000); - await on("mesh-controller.checked", (event) => setVerdict(client, event)); + await on("mesh-controller.checked", (event) => { + const c = (event.body ?? {}) as Checked; + // A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts). + if (!c.group && c.owner && c.repo && c.commit) return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event)); + return setVerdict(client, event); + }); + await on("node-build-agent.started", buildStarted); console.log("[gitea] watching for new repositories and merged pull requests"); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 1088a76b..7358ebde 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -45,7 +45,8 @@ "pull.closed" ], "consumes": [ - "mesh-controller.checked" + "mesh-controller.checked", + "node-build-agent.started" ], "listens": [ { diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts new file mode 100644 index 00000000..1178668c --- /dev/null +++ b/modules/gitea/test/gate.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// **A pending check says where it is, and the verdict lands last** (novox/hq issue 446). This module is the one writer +// of a head's mesh/merge-gate: the announcement's pending words, "building on since …" when the build seat +// says a machine took the check, and the verdict. The forge keeps the newest status of a context, so a pending word +// written after the verdict hides it — a pass reads pending, a failure hides under "building". + +const SHA = "a6b08de8507c1703528277e5cefc80548dbc9bb3"; + +// The build seat's `started` as a machine said it for novox/hq#309's check on 2026-10-11 (its log, read with the +// controller's `builds --log build-1791680941048622697`, opens at 03:09:01 on shanks); only the forge's address +// replaced. The shape is mesh-controller's link.BuildStart. +const startedOnShanks = { id: "build-1791680941048622697", repository: "http://127.0.0.1:20000/novox/hq.git", + ref: SHA, on: "shanks", at: "2026-10-11T01:09:01.204319Z" }; + +/** A forge that keeps the newest status per context, as gitea does, and whose writes take as long as a test says. */ +function aForge() { + const kept: Record = {}; + const written: string[] = []; + let hold: Promise | null = null; + let release = () => {}; + let readHold: Promise | null = null; + let readRelease = () => {}; + return { + kept, written, + /** The next writes wait until released: a request in flight. */ + holdWrites() { + hold = new Promise((r) => (release = r)); + }, + release() { + hold = null; + release(); + }, + async setCommitStatus(_o: string, _r: string, _sha: string, s: { state: string; context: string; description: string }) { + if (hold) await hold; + kept[s.context] = { state: s.state, description: s.description, updated_at: new Date().toISOString() }; + written.push(`${s.state} ${s.description}`); + }, + /** Reads answer what the forge held when asked, as late as the reads are held: the answer of a request in flight. */ + holdReads() { + readHold = new Promise((r) => (readRelease = r)); + }, + releaseReads() { + readHold = null; + readRelease(); + }, + async gateOf(_o: string, _r: string, _sha: string) { + const held = kept["mesh/merge-gate"] ? { ...kept["mesh/merge-gate"] } : undefined; + if (readHold) await readHold; + return held; + }, + }; +} + +/** The clock of the test: the head announced at 01:08:50Z. */ +const announcedAt = () => new Date("2026-10-11T01:08:50Z"); + +test("a build machine taking the check is said on the head: building on which machine, since the local time", async () => { + const { GateWriter, buildingWords } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); + assert.equal(forge.kept["mesh/merge-gate"].state, "pending"); + assert.equal(forge.kept["mesh/merge-gate"].description, buildingWords("shanks", new Date(startedOnShanks.at))); + assert.equal(buildingWords("shanks", new Date(2026, 9, 11, 3, 9, 1)), "building on shanks since 03:09"); +}); + +test("the verdict heard while a 'building' write is in flight lands last and stays", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + forge.holdWrites(); // the 'building' request is in flight when the verdict comes + const building = gates.started("novox", "hq", SHA, startedOnShanks); + await new Promise((r) => setImmediate(r)); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + forge.release(); + await Promise.all([building, verdict]); + assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", + `the verdict is not the newest status: ${forge.written.join(" | ")}`); +}); + +// The race the review of mesh-catalog#202 reproduced: a writer reads the forge's gate (pending), the verdict is written, +// then the writer writes "building" — and the pass reads pending until the next push. A head this process did not +// announce is the one case where the forge is read before writing. +test("the verdict written while a start's read of the forge is in flight stays the newest status", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; + const gates = new GateWriter(forge, announcedAt); + forge.holdReads(); + const building = gates.started("novox", "hq", SHA, startedOnShanks); + await new Promise((r) => setImmediate(r)); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + await new Promise((r) => setImmediate(r)); + forge.releaseReads(); + await Promise.all([building, verdict]); + assert.equal(`${forge.kept["mesh/merge-gate"].state} ${forge.kept["mesh/merge-gate"].description}`, "success pass", + `the verdict is not the newest status: ${forge.written.join(" | ")}`); +}); + +test("the verdict heard while a 'building' write still waits its turn: the 'building' is never written", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + forge.holdWrites(); // the pending words are still being written + const pending = gates.pending("novox", "hq", SHA); + const building = gates.started("novox", "hq", SHA, startedOnShanks); + const verdict = gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "fail: 0 of 4 compose" })); + forge.release(); + await Promise.all([pending, verdict]); + assert.equal(await building, false); + assert.equal(forge.kept["mesh/merge-gate"].state, "failure"); + assert.ok(!forge.written.some((w) => w.includes("building")), forge.written.join(" | ")); +}); + +test("a start heard after the verdict writes nothing", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + const gates = new GateWriter(forge, announcedAt); + await gates.pending("novox", "hq", SHA); + await gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "success", context: "mesh/merge-gate", description: "pass" })); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); + assert.equal(forge.kept["mesh/merge-gate"].state, "success"); +}); + +test("a start from before the check now pending (a run before a recheck, the stream's history) writes nothing", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + let now = announcedAt(); + const gates = new GateWriter(forge, () => now); + await gates.pending("novox", "hq", SHA); + await gates.verdict("novox", "hq", SHA, () => + forge.setCommitStatus("novox", "hq", SHA, { state: "failure", context: "mesh/merge-gate", description: "error" })); + now = new Date("2026-10-11T01:30:00Z"); // checked again: pending once more + await gates.pending("novox", "hq", SHA); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false, "the first run's start"); + assert.match(forge.kept["mesh/merge-gate"].description, /^waiting in the build queue since /); + assert.equal(await gates.started("novox", "hq", SHA, { ...startedOnShanks, at: "2026-10-11T01:31:00Z" }), true, + "the second run's start"); +}); + +test("a head this process did not announce (it restarted) is said only while the forge holds its gate pending", async () => { + const { GateWriter } = await import("../gate.ts"); + const forge = aForge(); + forge.kept["mesh/merge-gate"] = { state: "success", description: "pass", updated_at: "2026-10-11T01:20:00Z" }; + const gates = new GateWriter(forge, announcedAt); + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false); + forge.kept["mesh/merge-gate"] = { state: "pending", description: "waiting", updated_at: "2026-10-11T01:08:50Z" }; + assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), true); +}); + +test("a repository as the mesh clones it is told from another", async () => { + const { clonedAs } = await import("../gate.ts"); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/hq.git", "novox", "hq"), true); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false); + assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false); +}); diff --git a/modules/mesh-delivery/cmd/mesh-delivery/building.go b/modules/mesh-delivery/cmd/mesh-delivery/building.go deleted file mode 100644 index 16749b66..00000000 --- a/modules/mesh-delivery/cmd/mesh-delivery/building.go +++ /dev/null @@ -1,107 +0,0 @@ -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 -} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/effects.go b/modules/mesh-delivery/cmd/mesh-delivery/effects.go index cb60f204..02d6694b 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/effects.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/effects.go @@ -349,17 +349,6 @@ 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 diff --git a/modules/mesh-delivery/cmd/mesh-delivery/holder.go b/modules/mesh-delivery/cmd/mesh-delivery/holder.go index 0d5fa211..360746eb 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/holder.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/holder.go @@ -541,9 +541,6 @@ 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 diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go deleted file mode 100644 index 9729c714..00000000 --- a/modules/mesh-delivery/cmd/mesh-delivery/issue446_test.go +++ /dev/null @@ -1,113 +0,0 @@ -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) - } -} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/main.go b/modules/mesh-delivery/cmd/mesh-delivery/main.go index 42954829..6fe80a48 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/main.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/main.go @@ -29,10 +29,9 @@ 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, the controller's verdicts and walks, and a build machine -// taking an ask — said on a pending check's head (novox/hq issue 446). +// The events this owner follows: the forge's pull requests, and the controller's verdicts and walks. var followed = []string{"gitea.pull.updated", "gitea.pull.merged", "gitea.pull.closed", - ControllerSeat + ".checked", ControllerSeat + ".plan-moved", BuildSeat + ".started"} + ControllerSeat + ".checked", ControllerSeat + ".plan-moved"} func main() { h := &Holder{ @@ -134,13 +133,6 @@ 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 } diff --git a/modules/mesh-delivery/module.json b/modules/mesh-delivery/module.json index 781161a3..1215633c 100644 --- a/modules/mesh-delivery/module.json +++ b/modules/mesh-delivery/module.json @@ -28,8 +28,7 @@ "gitea.pull.merged", "gitea.pull.closed", "mesh-controller.checked", - "mesh-controller.plan-moved", - "node-build-agent.started" + "mesh-controller.plan-moved" ], "emits": [ "transition",