Skip to content

Commit 3094d0d

Browse files
committed
feat(review): add hourly sweep-liveness watchdog with self-heal re-enqueue (#3808)
Nothing previously noticed when the scheduled regate sweep stopped advancing a repo's last-regated marker -- the 2026-07-06 incident stayed silent for hours until a human queried the database directly. Add an hourly watchdog (flag-gated by GITTENSORY_SWEEP_WATCHDOG, default OFF) that scans the same acting-autonomy repo set the sweep itself covers. A repo with open PRs whose sweep marker hasn't advanced within a 45-minute window gets a structured sweep_liveness_stale log (Sentry- visible) and a single targeted agent-regate-sweep re-enqueue -- the same message shape the normal fan-out sends, so this only nudges the existing sweep rather than bypassing its own gating/dedup logic. A registered-but- uninstalled repo is never watched, since it can never get a per-PR fan-out regardless of nudging. Fails safe per-repo and at the top level.
1 parent 7d145f0 commit 3094d0d

11 files changed

Lines changed: 416 additions & 2 deletions

File tree

src/env.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,12 @@ declare global {
241241
* byte-identical to today. NOTE: this is read-only OBSERVABILITY only; the auto-tune / config-mutation
242242
* self-improve loop (src/review/auto-apply.ts) is deliberately NOT wired here — see ops-wire.ts. */
243243
GITTENSORY_REVIEW_OPS?: string;
244+
/** Self-heal: when truthy, an hourly watchdog scans the SAME acting-autonomy repo set the scheduled regate
245+
* sweep covers for a repo whose sweep marker hasn't advanced despite having open PRs to regate, emits a
246+
* structured `sweep_liveness_stale` log (Sentry-visible), and re-enqueues a targeted `agent-regate-sweep`
247+
* for just that repo. Default OFF — unset/false means the cron tick enqueues NO watchdog job (does no new
248+
* work), so the worker is byte-identical to today. */
249+
GITTENSORY_SWEEP_WATCHDOG?: string;
244250
/** Convergence (RAG retrieval): when truthy, the AI reviewer prompt gains a RELEVANT EXISTING CODE / DOCS
245251
* section — at review time the codebase vector index is queried for code/docs semantically related to the
246252
* PR's changed files (callers, related modules, existing conventions) and appended as additive reference

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { processDlqBatch } from "./queue/dlq";
55
import { processJob } from "./queue/processors";
66
import { isOrbBrokerEnabled } from "./orb/broker";
77
import { isOpsEnabled } from "./review/ops-wire";
8+
import { isSweepWatchdogEnabled } from "./review/sweep-watchdog";
89
import { isRagEnabled } from "./review/rag-wire";
910
import { isSelfTuneEnabled } from "./review/selftune-wire";
1011
import {
@@ -179,6 +180,11 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
179180
// review-outcome data. Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created,
180181
// so the cron tick does ZERO new work and the enqueued set is byte-identical to today.
181182
if (selfHostedReviews && isOpsEnabled(env)) jobs.push({ type: "ops-alerts", requestedBy: "schedule" });
183+
// Self-heal (flag GITTENSORY_SWEEP_WATCHDOG). Hourly liveness check over the same repo set the scheduled
184+
// regate sweep covers — re-enqueues a targeted sweep for any repo whose sweep marker has gone stale despite
185+
// having open PRs to regate. Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never
186+
// created, so the cron tick does ZERO new work and the enqueued set is byte-identical to today.
187+
if (selfHostedReviews && isSweepWatchdogEnabled(env)) jobs.push({ type: "sweep-liveness-watchdog", requestedBy: "schedule" });
182188
// Convergence (self-improve / auto-tune, flag GITTENSORY_REVIEW_SELFTUNE). Hourly self-improvement tick over
183189
// gittensory's own review-outcome data: compute tuning recommendations, shadow-soak any strictly-tightening
184190
// one, and auto-promote it to live only after the soak window passes the gate (TIGHTENING-ONLY, audited).

src/queue/processors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ import {
491491
import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config";
492492
import { resolveUnlinkedIssueMatchDisposition } from "../review/unlinked-issue-guardrail";
493493
import { isOpsEnabled, runOpsAlerts } from "../review/ops-wire";
494+
import { isSweepWatchdogEnabled, runSweepLivenessWatchdog } from "../review/sweep-watchdog";
494495
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
495496
import {
496497
isCloseHoldOnly,
@@ -1111,6 +1112,12 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
11111112
// flag-OFF does zero work here too. Read-only telemetry — never throws into the queue.
11121113
if (isOpsEnabled(env)) await runOpsAlerts(env);
11131114
return;
1115+
case "sweep-liveness-watchdog":
1116+
// Self-heal (flag GITTENSORY_SWEEP_WATCHDOG). Defense-in-depth: the cron only ENQUEUES this when the flag
1117+
// is ON, but a stale in-flight job that lands after a flag-flip must still no-op, so flag-OFF does zero
1118+
// work here too. Fails safe internally — never throws into the queue.
1119+
if (isSweepWatchdogEnabled(env)) await runSweepLivenessWatchdog(env);
1120+
return;
11141121
case "selftune":
11151122
// Convergence (self-improve / auto-tune, flag GITTENSORY_REVIEW_SELFTUNE). Defense-in-depth: the cron only
11161123
// ENQUEUES this when the flag is ON, but a stale in-flight job that lands after a flag-flip must still

src/review/sweep-watchdog.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Self-heal (flag-gated by GITTENSORY_SWEEP_WATCHDOG). The scheduled regate sweep (fanOutAgentRegateSweepJobs /
2+
// sweepRepoRegate, src/queue/processors.ts) advances every acting-autonomy repo's `last_regated_at` marker on
3+
// every successful sweep tick. When the sweep stops advancing that marker for a repo — a stalled cron, a wedged
4+
// per-repo failure that keeps recurring, a rate-limit floor that never clears — nothing in the system previously
5+
// noticed on its own; the 2026-07-06 incident stayed silent for hours until a human queried the database. This
6+
// watchdog closes that gap: it runs the SAME repo-selection the sweep itself uses, and for any repo with open
7+
// PRs whose sweep marker hasn't advanced within the staleness window, it (a) emits a structured Sentry-visible
8+
// log, and (b) re-enqueues a single targeted `agent-regate-sweep` for just that repo — the same message shape
9+
// the normal fan-out sends, so this is a pure "nudge," never a bypass of the sweep's own gating/dedup logic.
10+
//
11+
// Default OFF (like every other *-wire-adjacent convergence capability) — flag-OFF this module is never invoked
12+
// and the cron enqueues no watchdog job, byte-identical to today.
13+
14+
import { countOpenPullRequests, getLatestRegatedAt, listRepositories } from "../db/repositories";
15+
import { isAgentConfigured } from "../settings/autonomy";
16+
import { resolveRepositorySettings } from "../settings/repository-settings";
17+
import type { JobMessage } from "../types";
18+
import { errorMessage, nowIso } from "../utils/json";
19+
import { isConvergenceRepoAllowed, listConvergenceRepos } from "./cutover-gate";
20+
21+
/** True when the sweep-liveness watchdog is enabled. Flag-OFF (default) → the caller never invokes it, so the
22+
* cron enqueues no watchdog job and the queue processor no-ops on a stale in-flight one (defense-in-depth,
23+
* mirrors isOpsEnabled). */
24+
export function isSweepWatchdogEnabled(env: { GITTENSORY_SWEEP_WATCHDOG?: string | undefined }): boolean {
25+
return /^(1|true|yes|on)$/i.test(env.GITTENSORY_SWEEP_WATCHDOG ?? "");
26+
}
27+
28+
/** A repo's sweep is stale when it has open PRs to regate but its last-regated marker either never advanced or
29+
* hasn't advanced within the staleness window. A repo with NO open PRs is never stale — there is nothing for
30+
* the sweep to do, so a `null` marker there means "nothing to regate," not "the sweep stopped working." */
31+
export const SWEEP_STALENESS_THRESHOLD_MS = 45 * 60 * 1000;
32+
33+
export function isSweepStale(input: { openPullRequestCount: number; lastRegatedAt: string | null; nowMs: number }): boolean {
34+
if (input.openPullRequestCount === 0) return false;
35+
const lastMs = input.lastRegatedAt ? Date.parse(input.lastRegatedAt) : NaN;
36+
if (!Number.isFinite(lastMs)) return true;
37+
return input.nowMs - lastMs > SWEEP_STALENESS_THRESHOLD_MS;
38+
}
39+
40+
/** The same acting-autonomy repo set fanOutAgentRegateSweepJobs sweeps: the convergence allowlist
41+
* (GITTENSORY_REVIEW_REPOS) union the webhook-registered repos with acting autonomy, deduped case-insensitively.
42+
* Deliberately mirrors that function's own selection so the watchdog can never watch a DIFFERENT set of repos
43+
* than the sweep actually covers. */
44+
async function watchedRepos(env: Env): Promise<Array<{ fullName: string; installationId?: number }>> {
45+
const repositoriesByKey = new Map((await listRepositories(env)).map((repo) => [repo.fullName.toLowerCase(), repo]));
46+
const byKey = new Map<string, { fullName: string; installationId?: number }>();
47+
for (const repo of repositoriesByKey.values())
48+
byKey.set(repo.fullName.toLowerCase(), { fullName: repo.fullName, ...(typeof repo.installationId === "number" ? { installationId: repo.installationId } : {}) });
49+
for (const fullName of listConvergenceRepos(env)) {
50+
const repo = repositoriesByKey.get(fullName.toLowerCase());
51+
byKey.set(fullName.toLowerCase(), {
52+
fullName,
53+
...(typeof repo?.installationId === "number" ? { installationId: repo.installationId } : {}),
54+
});
55+
}
56+
const configured: Array<{ fullName: string; installationId?: number }> = [];
57+
for (const repo of byKey.values()) {
58+
try {
59+
const settings = await resolveRepositorySettings(env, repo.fullName);
60+
if (isConvergenceRepoAllowed(env, repo.fullName) || isAgentConfigured(settings.autonomy)) configured.push(repo);
61+
} catch {
62+
/* a settings blip on one repo must not abort the whole watchdog scan */
63+
}
64+
}
65+
return configured;
66+
}
67+
68+
export interface StaleSweepRepo {
69+
repoFullName: string;
70+
installationId?: number | undefined;
71+
openPullRequestCount: number;
72+
lastRegatedAt: string | null;
73+
ageMs: number;
74+
}
75+
76+
/**
77+
* The watchdog scan, run on the cron tick. FAILS SAFE: a per-repo error is logged and the scan continues; a
78+
* top-level error is swallowed (this is best-effort self-heal, never a reason to fail the queue). Only an
79+
* INSTALLED repo (installationId present) gets a self-heal re-enqueue — a registered-but-uninstalled repo never
80+
* gets a per-PR fan-out regardless (#sweep-uninstalled-budget-waste), so re-enqueuing its sweep would just spend
81+
* the shared GITHUB_PUBLIC_TOKEN budget on a sweep that can never act. Returns the stale repos found (for tests /
82+
* a caller that wants to act further).
83+
*
84+
* Caller MUST gate this on {@link isSweepWatchdogEnabled} — it is invoked only from the flag-ON cron path, so
85+
* flag-OFF this function is never reached and the cron does zero new work.
86+
*/
87+
export async function runSweepLivenessWatchdog(env: Env): Promise<StaleSweepRepo[]> {
88+
const found: StaleSweepRepo[] = [];
89+
const nowMs = Date.parse(nowIso());
90+
try {
91+
const repos = await watchedRepos(env);
92+
for (const repo of repos) {
93+
try {
94+
if (typeof repo.installationId !== "number") continue;
95+
const [openPullRequestCount, lastRegatedAt] = await Promise.all([countOpenPullRequests(env, repo.fullName), getLatestRegatedAt(env, repo.fullName)]);
96+
if (!isSweepStale({ openPullRequestCount, lastRegatedAt, nowMs })) continue;
97+
const lastMs = lastRegatedAt ? Date.parse(lastRegatedAt) : NaN;
98+
const ageMs = Number.isFinite(lastMs) ? nowMs - lastMs : Number.POSITIVE_INFINITY;
99+
found.push({ repoFullName: repo.fullName, installationId: repo.installationId, openPullRequestCount, lastRegatedAt, ageMs });
100+
console.error(
101+
JSON.stringify({
102+
level: "error",
103+
event: "sweep_liveness_stale",
104+
repository: repo.fullName,
105+
openPullRequestCount,
106+
lastRegatedAt,
107+
ageMs: Number.isFinite(ageMs) ? ageMs : null,
108+
}),
109+
);
110+
const message: JobMessage = { type: "agent-regate-sweep", requestedBy: "schedule", repoFullName: repo.fullName, installationId: repo.installationId };
111+
await env.JOBS.send(message).catch((error) => {
112+
console.error(JSON.stringify({ level: "error", event: "sweep_liveness_reenqueue_failed", repository: repo.fullName, error: errorMessage(error) }));
113+
});
114+
} catch (error) {
115+
console.error(JSON.stringify({ level: "error", event: "sweep_liveness_repo_error", repository: repo.fullName, message: errorMessage(error).slice(0, 200) }));
116+
}
117+
}
118+
} catch (error) {
119+
console.error(JSON.stringify({ level: "error", event: "sweep_liveness_error", message: errorMessage(error).slice(0, 200) }));
120+
}
121+
return found;
122+
}

src/selfhost/maintenance-admission.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const MAINTENANCE_JOB_TYPES: ReadonlySet<string> = new Set([
5757
"notify-evaluate",
5858
"notify-deliver",
5959
"ops-alerts",
60+
"sweep-liveness-watchdog",
6061
"selftune",
6162
"rag-index-repo",
6263
"backlog-convergence-sweep",

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ export type JobMessage =
187187
type: "ops-alerts";
188188
requestedBy: "schedule" | "api" | "test";
189189
}
190+
| {
191+
// Self-heal (flag-gated by GITTENSORY_SWEEP_WATCHDOG). Scan the SAME acting-autonomy repo set the
192+
// scheduled regate sweep covers for a stalled per-repo sweep (open PRs present, but none regated within
193+
// the staleness window) — emit a structured `sweep_liveness_stale` log AND re-enqueue a targeted
194+
// `agent-regate-sweep` for just that repo. Enqueued hourly by the cron ONLY when the flag is ON
195+
// (index.ts), so flag-OFF this job never exists.
196+
type: "sweep-liveness-watchdog";
197+
requestedBy: "schedule" | "api" | "test";
198+
}
190199
| {
191200
// Convergence (self-improve / auto-tune, flag-gated by GITTENSORY_REVIEW_SELFTUNE). Run the ported
192201
// self-improvement loop over gittensory's review-outcome data — compute tuning recommendations,

test/unit/index.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,47 @@ describe("worker entrypoint", () => {
604604
expect(sent.some((m) => m.type === "ops-alerts")).toBe(false);
605605
});
606606

607+
it("enqueues the sweep-liveness-watchdog job hourly ONLY when GITTENSORY_SWEEP_WATCHDOG is ON (flag-OFF is byte-identical)", async () => {
608+
const sentFor = async (watchdogFlag?: string): Promise<Array<import("../../src/types").JobMessage>> => {
609+
const sent: Array<import("../../src/types").JobMessage> = [];
610+
const env = createTestEnv({
611+
...(watchdogFlag === undefined ? {} : { GITTENSORY_SWEEP_WATCHDOG: watchdogFlag }),
612+
JOBS: {
613+
async send(message: import("../../src/types").JobMessage) {
614+
sent.push(message);
615+
},
616+
} as unknown as Queue,
617+
});
618+
const waitUntil: Promise<unknown>[] = [];
619+
await worker.scheduled(controllerFor("2026-05-25T05:00:00.000Z"), env, executionContext(waitUntil));
620+
await Promise.all(waitUntil);
621+
return sent;
622+
};
623+
624+
// Flag OFF (default) → no sweep-liveness-watchdog job; the enqueued set is unchanged from today.
625+
expect((await sentFor()).some((m) => m.type === "sweep-liveness-watchdog")).toBe(false);
626+
expect((await sentFor("false")).some((m) => m.type === "sweep-liveness-watchdog")).toBe(false);
627+
// Flag ON → exactly one sweep-liveness-watchdog job, enqueued in the hourly window.
628+
const on = await sentFor("true");
629+
expect(on.filter((m) => m.type === "sweep-liveness-watchdog")).toEqual([{ type: "sweep-liveness-watchdog", requestedBy: "schedule" }]);
630+
});
631+
632+
it("does NOT enqueue sweep-liveness-watchdog outside the hourly window even when GITTENSORY_SWEEP_WATCHDOG is ON", async () => {
633+
const sent: Array<import("../../src/types").JobMessage> = [];
634+
const env = createTestEnv({
635+
GITTENSORY_SWEEP_WATCHDOG: "true",
636+
JOBS: {
637+
async send(message: import("../../src/types").JobMessage) {
638+
sent.push(message);
639+
},
640+
} as unknown as Queue,
641+
});
642+
const waitUntil: Promise<unknown>[] = [];
643+
await worker.scheduled(controllerFor("2026-05-25T05:15:00.000Z"), env, executionContext(waitUntil)); // non-hourly
644+
await Promise.all(waitUntil);
645+
expect(sent.some((m) => m.type === "sweep-liveness-watchdog")).toBe(false);
646+
});
647+
607648
it("enqueues selftune hourly only when GITTENSORY_REVIEW_SELFTUNE is ON", async () => {
608649
const sentFor = async (
609650
selfTuneFlag?: string,

test/unit/queue.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21946,6 +21946,30 @@ describe("queue processors", () => {
2194621946
errors.mockRestore();
2194721947
});
2194821948

21949+
it("sweep-liveness-watchdog job no-ops when GITTENSORY_SWEEP_WATCHDOG is OFF (does no scan, no re-enqueue)", async () => {
21950+
const sent: import("../../src/types").JobMessage[] = [];
21951+
const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); // flag unset → OFF
21952+
await upsertRepositoryFromGitHub(env, { name: "stale-repo", full_name: "owner/stale-repo", private: false, owner: { login: "owner" } }, 9310);
21953+
await upsertRepositorySettings(env, { repoFullName: "owner/stale-repo", autonomy: { merge: "auto" } });
21954+
await upsertPullRequestFromGitHub(env, "owner/stale-repo", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" });
21955+
21956+
await processJob(env, { type: "sweep-liveness-watchdog", requestedBy: "test" });
21957+
21958+
expect(sent).toEqual([]);
21959+
});
21960+
21961+
it("sweep-liveness-watchdog job runs the liveness scan and re-enqueues a stale repo when GITTENSORY_SWEEP_WATCHDOG is ON", async () => {
21962+
const sent: import("../../src/types").JobMessage[] = [];
21963+
const env = createTestEnv({ GITTENSORY_SWEEP_WATCHDOG: "true", JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue });
21964+
await upsertRepositoryFromGitHub(env, { name: "stale-repo", full_name: "owner/stale-repo", private: false, owner: { login: "owner" } }, 9311);
21965+
await upsertRepositorySettings(env, { repoFullName: "owner/stale-repo", autonomy: { merge: "auto" } });
21966+
await upsertPullRequestFromGitHub(env, "owner/stale-repo", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" });
21967+
21968+
await processJob(env, { type: "sweep-liveness-watchdog", requestedBy: "test" });
21969+
21970+
expect(sent).toEqual([expect.objectContaining({ type: "agent-regate-sweep", repoFullName: "owner/stale-repo", installationId: 9311 })]);
21971+
});
21972+
2194921973
describe("type label decoupling (#label-decoupling)", () => {
2195021974
function stubTypeLabelFetch(prNumber: number, seen: { posted: string[]; removed: string[]; checkRunCreated: boolean }) {
2195121975
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {

0 commit comments

Comments
 (0)