issue 976: the forge's announcements get a deterministic message id; the announcement files go, the poll's watermark is kept in the module's state

This commit is contained in:
2026-10-17 13:52:47 +02:00
parent 2c8c1543ba
commit add91ff048
6 changed files with 274 additions and 59 deletions
+41 -56
View File
@@ -39,6 +39,7 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { commitStatusTool } from "./delivery.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
@@ -90,32 +91,17 @@ async function pollRepos(client: GiteaClient): Promise<void> {
// everything it sees then predates the watching.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
// What has been announced is no longer kept in a file (novox/hq ADR 0406 §6, §7, issue 976): an announcement carries a
// message id (announce.ts), so the same merge announced by the poll and by the forge's event, or again after a restart,
// is stored once by the bus — within its de-duplication window, and past it the consumers' idempotence per merge commit
// holds. This process remembers what it announced meanwhile, so it does not ask the forge for the same files twice.
// The one value kept is where the poll last looked from (`since`), in the module's own state: a merge made while this
// module was away, after it, is announced when it is back; one before it is history. With none kept — the first look
// ever — nothing is announced, because everything it sees then predates the watching.
const announced = new Set<string>();
let primedMerges = false;
// since is the moment the watching began: a merge made before it is history, whatever page of the
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
// rebuilt everything built from that repository, once per old merge (2026-09-28).
let since = "";
if (mergedRecord && existsSync(mergedRecord)) {
try {
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
const list = Array.isArray(kept) ? kept : kept.announced;
for (const sha of list) announced.add(sha);
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
primedMerges = true;
} catch {
// An unreadable record is treated as no record: prime again rather than re-announce history.
}
}
function keepAnnounced(): void {
if (!mergedRecord) return;
mkdirSync(join(mergedRecord, ".."), { recursive: true });
const tmp = mergedRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord);
}
let watermarkRead = false;
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
// repository's update time; asking every repository for its pull requests on every tick took longer than the
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
@@ -123,10 +109,14 @@ function keepAnnounced(): void {
let lastLook = "";
const MARGIN_MS = 60_000;
async function pollMerged(client: GiteaClient): Promise<void> {
if (!watermarkRead) {
since = await readWatermark();
primedMerges = since !== "";
watermarkRead = true;
}
const began = new Date().toISOString();
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
const repos = movedSince(await client.listAllRepos(), floor);
let changed = false;
for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
for (const pull of pulls) {
@@ -134,13 +124,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
}
announced.add(closedKey);
changed = true;
continue;
}
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
@@ -152,11 +141,11 @@ async function pollMerged(client: GiteaClient): Promise<void> {
}
else announced.add(pull.merge_commit_sha);
announced.add(pull.merge_commit_sha);
changed = true;
}
}
if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced();
// The next look starts from this one's start less the margin; kept, so a restart looks from there.
since = primedMerges ? new Date(Date.parse(began) - MARGIN_MS).toISOString() : began;
await keepWatermark(since);
primedMerges = true;
lastLook = began;
}
@@ -196,7 +185,7 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", {
await announce("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
@@ -226,32 +215,17 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
announced.delete(pull.merge_commit_sha);
throw err;
}
keepAnnounced();
return true;
}
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
// announces nothing twice; the first look on a machine with no record announces only what moved in the
// last day, so a forge's whole backlog is not checked at once.
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
// **Every new head of an open pull request is announced** (novox/hq to-be 45 §9): the mesh checks it against every
// machine of its facts before it merges. What this process announced is remembered in memory only (novox/hq ADR 0406
// §6, §7, issue 976): the announcement carries a message id (announce.ts), so the poll's, the forge event's and the
// merge tool's are one to the bus. After a start, the first look announces a head only if the forge shows no merge check
// of it yet — a head with one was announced — and only what moved in the last day, so a forge's whole backlog is not
// checked at once and a restart asks no check again against a trunk that moved.
let heads: Announced = {};
let primedPulls = false;
if (pullsRecord && existsSync(pullsRecord)) {
try {
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
primedPulls = true;
} catch {
// An unreadable record is no record: the first look announces only the last day's.
}
}
function keepHeads(): void {
if (!pullsRecord) return;
mkdirSync(join(pullsRecord, ".."), { recursive: true });
const tmp = pullsRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ heads }));
renameSync(tmp, pullsRecord);
}
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = `${repo.full_name}#${pull.number}`;
@@ -261,13 +235,12 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
if (!claimHead(heads, key, sha)) return false;
try {
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
await announce("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
} catch (err) {
if (before === undefined) delete heads[key];
else heads[key] = before;
throw err;
}
keepHeads();
// A sibling's new head puts out of date every verdict that read the sibling at another head (ADR 0382 §5).
await siblingMoves(client, repo.full_name, pull.number);
// Said, so an operator knows the mesh was asked to check it.
@@ -279,6 +252,16 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
/** Whether the forge shows a merge check of the head, in any state: only an announced head has one. */
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
try {
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks;
} catch {
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
}
}
let lastPullLook = "";
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
@@ -288,7 +271,6 @@ async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
let changed = false;
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
let sweep = Date.now() - lastSweep >= SWEEP_MS;
@@ -309,14 +291,17 @@ async function pollPulls(client: GiteaClient): Promise<void> {
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
changed = true;
}
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
}
if (!primedPulls || changed) keepHeads();
if (changedTrunks) keepTrunks();
changedTrunks = false;
primedPulls = true;