Merge pull request 'Bind the gitea bundle's event consumers again until they hold, so verdicts reach the forge (issue 1793)' (#428) from fix/1793-verdict-reaches-the-forge into main

This commit was merged in pull request #428.
This commit is contained in:
2026-10-21 22:44:16 +00:00
3 changed files with 68 additions and 3 deletions
+30
View File
@@ -0,0 +1,30 @@
// **A consumer that cannot be bound yet is bound again, never fatal** (novox/hq issue 1793). When the tool runtime
// starts this bundle, the runtime's answer to "bind this module's consumer" can be "no responders" for a few seconds (the
// runtime restarted by a delivery, the bus not yet serving the stream). That rejection, left unhandled at the top of
// index.ts, ended the bundle: nothing heard `mesh-controller.checked` again, and a verdict that landed later never
// reached the forge's statuses. A bind that fails is said and asked again with a growing wait until it holds.
/** How long to wait before the nth new attempt (1-based): 2 s, 5 s, 10 s, then every 30 s. */
export function bindWait(attempt: number): number {
return [2_000, 5_000, 10_000][attempt - 1] ?? 30_000;
}
/**
* Binds with `bind`, asking again after each failure, until it holds. `sleep` is the wait; `say` how a failure is said.
* Never rejects; resolves once the bind holds.
*/
export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
say: (line: string) => void = (line) => console.error(line)): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await bind();
if (attempt > 1) say(`[gitea] ${what} is bound after ${attempt} attempts`);
return;
} catch (err) {
const wait = bindWait(attempt);
say(`[gitea] ${what} could not be bound (${err instanceof Error ? err.message : String(err)}); asking again in ${wait / 1000}s`);
await sleep(wait);
}
}
}
+5 -3
View File
@@ -47,6 +47,7 @@ import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateS
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindUntilHeld } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -590,7 +591,8 @@ if (gitea) {
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
await on("mesh-controller.checked", (event) => {
// Bound again until it holds (bind.ts, issue 1793): a refusal at the start must not end the bundle.
await bindUntilHeld("mesh-controller.checked", () => 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).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
@@ -598,7 +600,7 @@ if (gitea) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
});
await on("node-build-agent.started", buildStarted);
}));
await bindUntilHeld("node-build-agent.started", () => on("node-build-agent.started", buildStarted));
console.log("[gitea] watching for new repositories and merged pull requests");
}
+33
View File
@@ -0,0 +1,33 @@
// **A consumer that cannot be bound yet is bound again** (novox/hq issue 1793): the runtime's "no responders" at the
// bundle's start, seen 2026-10-22 00:28:37 on mesh-catalog#427's verdict, ended the bundle and the verdict was never
// written on the forge.
import { test } from "node:test";
import assert from "node:assert/strict";
import { bindUntilHeld, bindWait } from "../dist/bind.js";
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
let calls = 0;
const waits: number[] = [];
const said: string[] = [];
await bindUntilHeld("mesh-controller.checked", async () => {
calls++;
if (calls < 4) throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request");
}, async (ms) => { waits.push(ms); }, (l) => said.push(l));
assert.equal(calls, 4);
assert.deepEqual(waits, [2_000, 5_000, 10_000]);
assert.equal(said.length, 4);
assert.match(said[0], /no responders available for request.*asking again in 2s/);
assert.match(said[3], /is bound after 4 attempts/);
});
test("a bind that holds the first time says nothing and waits for nothing", async () => {
const said: string[] = [];
await bindUntilHeld("x", async () => {}, async () => assert.fail("waited"), (l) => said.push(l));
assert.deepEqual(said, []);
});
test("the wait grows to 30 s and stays", () => {
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
});