diff --git a/modules/gitea/bind.ts b/modules/gitea/bind.ts new file mode 100644 index 00000000..59d1fae0 --- /dev/null +++ b/modules/gitea/bind.ts @@ -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, + sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), + say: (line: string) => void = (line) => console.error(line)): Promise { + 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); + } + } +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 680769cd..b5c234d1 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -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"); } diff --git a/modules/gitea/test/bind.test.ts b/modules/gitea/test/bind.test.ts new file mode 100644 index 00000000..7f8d00e8 --- /dev/null +++ b/modules/gitea/test/bind.test.ts @@ -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]); +});