Make the gitea module the one writer of mesh/merge-gate, so a verdict always lands last (hq issue 446)
mesh/merge-gate pass: builds gitea → 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 covered after its failure: a later walk that contains it was delivered: plan-1791682027628755615 (novox/mesh-catalog at 87b0d3a5 into main,…

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.
This commit is contained in:
2026-10-11 03:23:52 +02:00
parent 4254d17322
commit 1cf56688c1
10 changed files with 348 additions and 256 deletions
+139
View File
@@ -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
// <machine> 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<void>;
/** 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<void>;
}
export class GateWriter {
private heads = new Map<string, Head>();
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<void>): Promise<void> {
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<void>): Promise<void> {
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<void> {
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<boolean> {
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<void>): Promise<void> {
const h = this.head(owner, repo, sha);
h.verdict = true;
return this.turn(h, write);
}
}
+40 -9
View File
@@ -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<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 — 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<void> {
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");
}
+2 -1
View File
@@ -45,7 +45,8 @@
"pull.closed"
],
"consumes": [
"mesh-controller.checked"
"mesh-controller.checked",
"node-build-agent.started"
],
"listens": [
{
+164
View File
@@ -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 <machine> 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<string, { state: string; description: string; updated_at: string }> = {};
const written: string[] = [];
let hold: Promise<void> | null = null;
let release = () => {};
let readHold: Promise<void> | null = null;
let readRelease = () => {};
return {
kept, written,
/** The next writes wait until released: a request in flight. */
holdWrites() {
hold = new Promise<void>((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<void>((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);
});
@@ -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
}
@@ -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
@@ -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
@@ -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)
}
}
@@ -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
}
+1 -2
View File
@@ -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",