From 2b572a2f761d824c7dc0960e8ee5bc4545eec95b Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Thu, 16 Jul 2026 12:44:18 -0400 Subject: [PATCH 1/2] feat(config): make sweepWatchdog staleness threshold configurable (#6594) --- .loopover.yml.example | 7 +- config/examples/loopover.full.yml | 6 +- .../loopover-engine/src/focus-manifest.ts | 19 ++- src/queue/job-dispatch.ts | 4 +- src/review/sweep-watchdog.ts | 55 +++++-- test/unit/focus-manifest.test.ts | 51 ++++++- test/unit/sweep-watchdog.test.ts | 136 ++++++++++++++++-- 7 files changed, 241 insertions(+), 37 deletions(-) diff --git a/.loopover.yml.example b/.loopover.yml.example index 2ef33e6884..ba33ebda42 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1276,14 +1276,15 @@ settings: # upstreamDriftIssues: # enabled: true # Bool. Default: false (the env var decides instead). -# Fleet-wide sweep-liveness watchdog cron (#6558 / #6275): config-as-code override for the +# Fleet-wide sweep-liveness watchdog cron (#6558 / #6275 / #6594): config-as-code override for the # LOOPOVER_SWEEP_WATCHDOG flag that gates the hourly sweep-liveness check. Operator-level, not per-repo -- # only meaningful on the loopover self-repo's own manifest; a present block there wins over the env var. # Distinct from the per-repo FORCE-OFF under `review.sweepWatchdog` (which only excludes one repo from the # scan set once the fleet gate is ON). # sweepWatchdog: -# enabled: true # Bool. Default: false (the env var decides instead). - +# enabled: true # Bool. Default: false (the env var decides instead). +# staleAfterMinutes: 90 # Optional positive int (#6594). Minutes without a sweep-marker advance before +# # a repo with open PRs is treated as stale. Omit ⇒ hardcoded 45-minute default. # Fleet-wide open-PR reconciliation cron (#6558 / #6275): config-as-code override for the # LOOPOVER_PR_RECONCILIATION flag that gates the fast open-PR reconciliation pass. Same shape and # precedence as `sweepWatchdog:` above. Distinct from the per-repo FORCE-OFF under `review.prReconciliation`. diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 7c738256e9..d12a5f212a 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -1290,13 +1290,15 @@ settings: # upstreamDriftIssues: # enabled: true # Bool. Default: false (the env var decides instead). -# Fleet-wide sweep-liveness watchdog cron (#6558 / #6275): config-as-code override for the +# Fleet-wide sweep-liveness watchdog cron (#6558 / #6275 / #6594): config-as-code override for the # LOOPOVER_SWEEP_WATCHDOG flag that gates the hourly sweep-liveness check. Operator-level, not per-repo -- # only meaningful on the loopover self-repo's own manifest; a present block there wins over the env var. # Distinct from the per-repo FORCE-OFF under `review.sweepWatchdog` (which only excludes one repo from the # scan set once the fleet gate is ON). # sweepWatchdog: -# enabled: true # Bool. Default: false (the env var decides instead). +# enabled: true # Bool. Default: false (the env var decides instead). +# staleAfterMinutes: 90 # Optional positive int (#6594). Minutes without a sweep-marker advance before +# # a repo with open PRs is treated as stale. Omit ⇒ hardcoded 45-minute default. # Fleet-wide open-PR reconciliation cron (#6558 / #6275): config-as-code override for the # LOOPOVER_PR_RECONCILIATION flag that gates the fast open-PR reconciliation pass. Same shape and diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index a8eee08d8f..d3fa83790c 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -443,6 +443,8 @@ export type FocusManifestUpstreamDriftIssuesConfig = { export type FocusManifestSweepWatchdogConfig = { present: boolean; enabled: boolean; + /** Optional staleness window in minutes (#6594). null ⇒ caller keeps the hardcoded 45-minute default. */ + staleAfterMinutes: number | null; }; /** @@ -1245,6 +1247,7 @@ const EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG: FocusManifestUpstreamDriftIssuesConfig const EMPTY_SWEEP_WATCHDOG_CONFIG: FocusManifestSweepWatchdogConfig = { present: false, enabled: false, + staleAfterMinutes: null, }; const EMPTY_PR_RECONCILIATION_CONFIG: FocusManifestPrReconciliationConfig = { @@ -2134,8 +2137,9 @@ export function upstreamDriftIssuesConfigToJson(config: FocusManifestUpstreamDri } /** - * Parse the optional top-level `sweepWatchdog:` mapping (#6558 / #6275). Mirrors {@link parseOpsConfig} - * exactly — `enabled` is the only field. Distinct from per-repo `review.sweepWatchdog`. + * Parse the optional top-level `sweepWatchdog:` mapping (#6558 / #6275 / #6594). Mirrors {@link parseOpsConfig} + * for `enabled`, plus an optional `staleAfterMinutes` positive integer. Invalid / non-positive values warn and + * fall back to null (caller keeps the hardcoded 45-minute default). Distinct from per-repo `review.sweepWatchdog`. */ function parseSweepWatchdogConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestSweepWatchdogConfig { if (value === undefined || value === null) return { ...EMPTY_SWEEP_WATCHDOG_CONFIG }; @@ -2145,14 +2149,21 @@ function parseSweepWatchdogConfig(value: JsonValue | undefined, warnings: string } const record = value as Record; const enabled = normalizeOptionalBoolean(record.enabled, "sweepWatchdog.enabled", warnings) ?? false; - return { present: true, enabled }; + const staleAfterMinutes = normalizeOptionalPositiveInteger( + record.staleAfterMinutes, + "sweepWatchdog.staleAfterMinutes", + warnings, + ); + return { present: true, enabled, staleAfterMinutes }; } /** Serialize a sweepWatchdog config back into the parse-compatible shape so a cached snapshot round-trips * through {@link parseSweepWatchdogConfig} unchanged. Returns null when nothing is configured. */ export function sweepWatchdogConfigToJson(config: FocusManifestSweepWatchdogConfig): JsonValue { if (!config.present) return null; - return { enabled: config.enabled }; + const out: Record = { enabled: config.enabled }; + if (config.staleAfterMinutes !== null) out.staleAfterMinutes = config.staleAfterMinutes; + return out; } /** diff --git a/src/queue/job-dispatch.ts b/src/queue/job-dispatch.ts index e90984db43..cc6a9402b2 100644 --- a/src/queue/job-dispatch.ts +++ b/src/queue/job-dispatch.ts @@ -301,7 +301,9 @@ export async function processJob(env: Env, message: JobMessage): Promise { // no-op, so disabled does zero work here too. Fails safe internally — never throws into the queue. { const sweepWatchdogManifestOverride = await resolveSweepWatchdogManifestOverride(env); - if (isSweepWatchdogEnabled(env, sweepWatchdogManifestOverride)) await runSweepLivenessWatchdog(env); + if (isSweepWatchdogEnabled(env, sweepWatchdogManifestOverride)) { + await runSweepLivenessWatchdog(env, sweepWatchdogManifestOverride); + } } return; case "loop-escalation-sweep": diff --git a/src/review/sweep-watchdog.ts b/src/review/sweep-watchdog.ts index 26b59484b3..f1e6a19340 100644 --- a/src/review/sweep-watchdog.ts +++ b/src/review/sweep-watchdog.ts @@ -20,11 +20,16 @@ import type { JobMessage } from "../types"; import { errorMessage, nowIso } from "../utils/json"; import { isConvergenceRepoAllowed, listConvergenceRepos } from "./cutover-gate"; -/** A manifest-sourced enable override (#6558 / #6275) -- the top-level `sweepWatchdog` block of the - * loopover self-repo's `.loopover.yml` (see FocusManifestSweepWatchdogConfig). Distinct from the +/** A manifest-sourced enable/threshold override (#6558 / #6275 / #6594) -- the top-level `sweepWatchdog` + * block of the loopover self-repo's `.loopover.yml` (see FocusManifestSweepWatchdogConfig). Distinct from the * per-repo FORCE-OFF under `review.sweepWatchdog`. `present: false` means "no override configured", - * not "disabled" -- the caller falls through to the env var in that case. Mirrors OpsManifestOverride. */ -export type SweepWatchdogManifestOverride = { present: boolean; enabled: boolean }; + * not "disabled" -- the caller falls through to the env var in that case. `staleAfterMinutes: null` means + * keep the hardcoded {@link SWEEP_STALENESS_THRESHOLD_MS} default. Mirrors OpsManifestOverride. */ +export type SweepWatchdogManifestOverride = { + present: boolean; + enabled: boolean; + staleAfterMinutes: number | null; +}; /** True when the sweep-liveness watchdog is enabled. Config-as-code (#6558 / #6275): a present top-level * `sweepWatchdog` manifest block on the loopover self-repo wins outright; otherwise falls back to the @@ -58,12 +63,16 @@ export async function resolveSweepWatchdogManifestOverride(env: Env, nowMs: numb try { const manifest = await loadRepoFocusManifest(env, resolveLoopOverSelfRepoFullName(env)); const config = manifest.sweepWatchdog; - const override = { present: config.present, enabled: config.enabled }; + const override: SweepWatchdogManifestOverride = { + present: config.present, + enabled: config.enabled, + staleAfterMinutes: config.staleAfterMinutes, + }; sweepWatchdogManifestOverrideCache = { override, at: nowMs }; return override; } catch (error) { console.warn(JSON.stringify({ event: "sweep_watchdog_manifest_override_error", message: errorMessage(error).slice(0, 200) })); - const override = { present: false, enabled: false }; + const override: SweepWatchdogManifestOverride = { present: false, enabled: false, staleAfterMinutes: null }; sweepWatchdogManifestOverrideCache = { override, at: nowMs }; return override; } @@ -79,11 +88,31 @@ export function clearSweepWatchdogManifestOverrideCacheForTest(): void { * the sweep to do, so a `null` marker there means "nothing to regate," not "the sweep stopped working." */ export const SWEEP_STALENESS_THRESHOLD_MS = 45 * 60 * 1000; -export function isSweepStale(input: { openPullRequestCount: number; lastRegatedAt: string | null; nowMs: number }): boolean { +/** Resolve the effective staleness threshold in ms from a manifest override (#6594). Absent / null minutes + * keep {@link SWEEP_STALENESS_THRESHOLD_MS}; never returns zero/negative/NaN. */ +export function resolveSweepStalenessThresholdMs( + manifestOverride?: SweepWatchdogManifestOverride | undefined, +): number { + const minutes = manifestOverride?.present ? manifestOverride.staleAfterMinutes : null; + if (typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0) return minutes * 60_000; + return SWEEP_STALENESS_THRESHOLD_MS; +} + +export function isSweepStale(input: { + openPullRequestCount: number; + lastRegatedAt: string | null; + nowMs: number; + /** Optional override in milliseconds (#6594); omitted ⇒ {@link SWEEP_STALENESS_THRESHOLD_MS}. */ + staleAfterMs?: number; +}): boolean { if (input.openPullRequestCount === 0) return false; const lastMs = input.lastRegatedAt ? Date.parse(input.lastRegatedAt) : NaN; if (!Number.isFinite(lastMs)) return true; - return input.nowMs - lastMs > SWEEP_STALENESS_THRESHOLD_MS; + const thresholdMs = + typeof input.staleAfterMs === "number" && Number.isFinite(input.staleAfterMs) && input.staleAfterMs > 0 + ? input.staleAfterMs + : SWEEP_STALENESS_THRESHOLD_MS; + return input.nowMs - lastMs > thresholdMs; } /** The same acting-autonomy repo set fanOutAgentRegateSweepJobs sweeps: the convergence allowlist @@ -149,16 +178,22 @@ export interface StaleSweepRepo { * Caller MUST gate this on {@link isSweepWatchdogEnabled} — it is invoked only from the flag-ON cron path, so * flag-OFF this function is never reached and the cron does zero new work. */ -export async function runSweepLivenessWatchdog(env: Env): Promise { +export async function runSweepLivenessWatchdog( + env: Env, + /** Optional pre-resolved override (#6594); when omitted, looks up the self-repo manifest (cached). */ + manifestOverride?: SweepWatchdogManifestOverride, +): Promise { const found: StaleSweepRepo[] = []; const nowMs = Date.parse(nowIso()); + const override = manifestOverride ?? (await resolveSweepWatchdogManifestOverride(env, nowMs)); + const staleAfterMs = resolveSweepStalenessThresholdMs(override); try { const repos = await watchedRepos(env); for (const repo of repos) { try { if (typeof repo.installationId !== "number") continue; const [openPullRequestCount, lastRegatedAt] = await Promise.all([countOpenPullRequests(env, repo.fullName), getLatestRegatedAt(env, repo.fullName)]); - if (!isSweepStale({ openPullRequestCount, lastRegatedAt, nowMs })) continue; + if (!isSweepStale({ openPullRequestCount, lastRegatedAt, nowMs, staleAfterMs })) continue; const lastMs = lastRegatedAt ? Date.parse(lastRegatedAt) : NaN; const ageMs = Number.isFinite(lastMs) ? nowMs - lastMs : Number.POSITIVE_INFINITY; found.push({ repoFullName: repo.fullName, installationId: repo.installationId, openPullRequestCount, lastRegatedAt, ageMs }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index db91fb34f2..0ba830b6d4 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -503,6 +503,7 @@ describe(".loopover.yml.example field-exhaustiveness (#1670)", () => { const SWEEP_WATCHDOG_FIELD_TOKENS = { enabled: "enabled:", + staleAfterMinutes: "staleAfterMinutes:", } satisfies Record, string>; it.each(Object.entries(SWEEP_WATCHDOG_FIELD_TOKENS))("documents sweepWatchdog.%s", (_field, token) => { @@ -945,7 +946,7 @@ describe("compileFocusManifestPolicy", () => { publicStats: { present: false, enabled: false }, draftFlow: { present: false, enabled: false }, upstreamDriftIssues: { present: false, enabled: false }, - sweepWatchdog: { present: false, enabled: false }, + sweepWatchdog: { present: false, enabled: false, staleAfterMinutes: null }, prReconciliation: { present: false, enabled: false }, federatedIntelligence: { present: false, enabled: false, collectorUrl: null, collectorMode: null }, warnings: [], @@ -2143,15 +2144,19 @@ describe("parseFocusManifest gate config", () => { }); }); - describe("sweepWatchdog: (#6558 / #6275, fleet-wide sweep-liveness watchdog config-as-code override)", () => { + describe("sweepWatchdog: (#6558 / #6275 / #6594, fleet-wide sweep-liveness watchdog config-as-code override)", () => { it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => { const m = parseFocusManifest({}); - expect(m.sweepWatchdog).toEqual({ present: false, enabled: false }); + expect(m.sweepWatchdog).toEqual({ present: false, enabled: false, staleAfterMinutes: null }); expect(m.present).toBe(false); }); it("treats an explicit null the same as an omitted key", () => { - expect(parseFocusManifest({ sweepWatchdog: null }).sweepWatchdog).toEqual({ present: false, enabled: false }); + expect(parseFocusManifest({ sweepWatchdog: null }).sweepWatchdog).toEqual({ + present: false, + enabled: false, + staleAfterMinutes: null, + }); }); it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { @@ -2165,13 +2170,13 @@ describe("parseFocusManifest gate config", () => { it("parses enabled: true, making the manifest present", () => { const m = parseFocusManifest({ sweepWatchdog: { enabled: true } }); - expect(m.sweepWatchdog).toEqual({ present: true, enabled: true }); + expect(m.sweepWatchdog).toEqual({ present: true, enabled: true, staleAfterMinutes: null }); expect(m.present).toBe(true); }); it("parses enabled: false explicitly, still marking the manifest present (present is a real override, off)", () => { const m = parseFocusManifest({ sweepWatchdog: { enabled: false } }); - expect(m.sweepWatchdog).toEqual({ present: true, enabled: false }); + expect(m.sweepWatchdog).toEqual({ present: true, enabled: false, staleAfterMinutes: null }); expect(m.present).toBe(true); }); @@ -2181,9 +2186,41 @@ describe("parseFocusManifest gate config", () => { expect(m.warnings.some((w) => /sweepWatchdog\.enabled/.test(w))).toBe(true); }); + it("parses a valid staleAfterMinutes positive integer (#6594)", () => { + const m = parseFocusManifest({ sweepWatchdog: { enabled: true, staleAfterMinutes: 90 } }); + expect(m.sweepWatchdog).toEqual({ present: true, enabled: true, staleAfterMinutes: 90 }); + }); + + it("leaves staleAfterMinutes null when the field is absent (#6594)", () => { + const m = parseFocusManifest({ sweepWatchdog: { enabled: true } }); + expect(m.sweepWatchdog.staleAfterMinutes).toBeNull(); + }); + + it.each([ + ["string", "ninety" as unknown], + ["zero", 0], + ["negative", -5], + ["NaN", Number.NaN], + ["float", 1.5], + ] as const)("warns and falls back to null when staleAfterMinutes is %s (#6594)", (_label, value) => { + const m = parseFocusManifest({ sweepWatchdog: { enabled: true, staleAfterMinutes: value as number } }); + expect(m.sweepWatchdog.staleAfterMinutes).toBeNull(); + expect(m.warnings.some((w) => /sweepWatchdog\.staleAfterMinutes/.test(w))).toBe(true); + }); + it("round-trips through sweepWatchdogConfigToJson → parseFocusManifest unchanged", () => { + const m = parseFocusManifest({ sweepWatchdog: { enabled: true, staleAfterMinutes: 90 } }); + expect(parseFocusManifest({ sweepWatchdog: sweepWatchdogConfigToJson(m.sweepWatchdog) }).sweepWatchdog).toEqual( + m.sweepWatchdog, + ); + }); + + it("round-trips enabled-only configs without inventing a staleAfterMinutes key", () => { const m = parseFocusManifest({ sweepWatchdog: { enabled: true } }); - expect(parseFocusManifest({ sweepWatchdog: sweepWatchdogConfigToJson(m.sweepWatchdog) }).sweepWatchdog).toEqual(m.sweepWatchdog); + expect(sweepWatchdogConfigToJson(m.sweepWatchdog)).toEqual({ enabled: true }); + expect(parseFocusManifest({ sweepWatchdog: sweepWatchdogConfigToJson(m.sweepWatchdog) }).sweepWatchdog).toEqual( + m.sweepWatchdog, + ); }); it("sweepWatchdogConfigToJson returns null for an absent config", () => { diff --git a/test/unit/sweep-watchdog.test.ts b/test/unit/sweep-watchdog.test.ts index 56a1b7a951..0b99f26198 100644 --- a/test/unit/sweep-watchdog.test.ts +++ b/test/unit/sweep-watchdog.test.ts @@ -3,6 +3,7 @@ import { clearSweepWatchdogManifestOverrideCacheForTest, isSweepStale, isSweepWatchdogEnabled, + resolveSweepStalenessThresholdMs, resolveSweepWatchdogManifestOverride, runSweepLivenessWatchdog, SWEEP_STALENESS_THRESHOLD_MS, @@ -22,12 +23,12 @@ describe("isSweepWatchdogEnabled — default OFF, truthy convention", () => { }); it("a present manifest override wins outright over the env flag, in both directions (#6558)", () => { - expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "false" }, { present: true, enabled: true })).toBe(true); - expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "true" }, { present: true, enabled: false })).toBe(false); + expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "false" }, { present: true, enabled: true, staleAfterMinutes: null })).toBe(true); + expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "true" }, { present: true, enabled: false, staleAfterMinutes: null })).toBe(false); }); it("falls back to the env flag when the manifest override is not present", () => { - expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "true" }, { present: false, enabled: false })).toBe(true); + expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "true" }, { present: false, enabled: false, staleAfterMinutes: null })).toBe(true); expect(isSweepWatchdogEnabled({ LOOPOVER_SWEEP_WATCHDOG: "false" }, undefined)).toBe(false); }); }); @@ -44,14 +45,33 @@ describe("resolveSweepWatchdogManifestOverride — config-as-code lookup (#6558) const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); await upsertRepoFocusManifest(env, SELF_REPO, { sweepWatchdog: { enabled: true } }); - expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ present: true, enabled: true }); + expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ + present: true, + enabled: true, + staleAfterMinutes: null, + }); }); it("returns present: false when the self-repo has no sweepWatchdog block configured", async () => { const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); await upsertRepoFocusManifest(env, SELF_REPO, { wantedPaths: ["src/"] }); - expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ present: false, enabled: false }); + expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ + present: false, + enabled: false, + staleAfterMinutes: null, + }); + }); + + it("carries staleAfterMinutes through from the self-repo manifest (#6594)", async () => { + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); + await upsertRepoFocusManifest(env, SELF_REPO, { sweepWatchdog: { enabled: true, staleAfterMinutes: 90 } }); + + expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ + present: true, + enabled: true, + staleAfterMinutes: 90, + }); }); it("degrades to present: false (never throws) when the manifest load itself fails", async () => { @@ -66,7 +86,11 @@ describe("resolveSweepWatchdogManifestOverride — config-as-code lookup (#6558) }); const warnings = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ present: false, enabled: false }); + expect(await resolveSweepWatchdogManifestOverride(env)).toEqual({ + present: false, + enabled: false, + staleAfterMinutes: null, + }); expect(warnings.mock.calls.map((c) => String(c[0])).some((line) => line.includes("sweep_watchdog_manifest_override_error"))).toBe(true); }); @@ -74,22 +98,38 @@ describe("resolveSweepWatchdogManifestOverride — config-as-code lookup (#6558) const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); await upsertRepoFocusManifest(env, SELF_REPO, { sweepWatchdog: { enabled: true } }); const t0 = Date.parse("2026-07-16T00:00:00Z"); - expect(await resolveSweepWatchdogManifestOverride(env, t0)).toEqual({ present: true, enabled: true }); + expect(await resolveSweepWatchdogManifestOverride(env, t0)).toEqual({ + present: true, + enabled: true, + staleAfterMinutes: null, + }); env.DB.prepare = (() => { throw new Error("should not be queried on a cache hit"); }) as typeof env.DB.prepare; - expect(await resolveSweepWatchdogManifestOverride(env, t0 + 30_000)).toEqual({ present: true, enabled: true }); + expect(await resolveSweepWatchdogManifestOverride(env, t0 + 30_000)).toEqual({ + present: true, + enabled: true, + staleAfterMinutes: null, + }); }); it("re-reads the manifest once the 60s TTL has elapsed", async () => { const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); await upsertRepoFocusManifest(env, SELF_REPO, { sweepWatchdog: { enabled: true } }); const t0 = Date.parse("2026-07-16T00:00:00Z"); - expect(await resolveSweepWatchdogManifestOverride(env, t0)).toEqual({ present: true, enabled: true }); + expect(await resolveSweepWatchdogManifestOverride(env, t0)).toEqual({ + present: true, + enabled: true, + staleAfterMinutes: null, + }); await upsertRepoFocusManifest(env, SELF_REPO, { sweepWatchdog: { enabled: false } }); - expect(await resolveSweepWatchdogManifestOverride(env, t0 + 60_001)).toEqual({ present: true, enabled: false }); + expect(await resolveSweepWatchdogManifestOverride(env, t0 + 60_001)).toEqual({ + present: true, + enabled: false, + staleAfterMinutes: null, + }); }); }); @@ -118,6 +158,44 @@ describe("isSweepStale (#audit-sweep-fanout-isolation follow-up)", () => { const lastRegatedAt = new Date(NOW - (SWEEP_STALENESS_THRESHOLD_MS + 1000)).toISOString(); expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt, nowMs: NOW })).toBe(true); }); + + it("an explicit staleAfterMs override changes the stale/not-stale boundary (#6594)", () => { + const overrideMs = 10 * 60_000; + const withinOverride = new Date(NOW - (overrideMs - 1000)).toISOString(); + const outsideOverride = new Date(NOW - (overrideMs + 1000)).toISOString(); + // Still inside the hardcoded 45m default, but outside a 10m override → stale under the override only. + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt: withinOverride, nowMs: NOW, staleAfterMs: overrideMs })).toBe( + false, + ); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt: outsideOverride, nowMs: NOW, staleAfterMs: overrideMs })).toBe( + true, + ); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt: outsideOverride, nowMs: NOW })).toBe(false); + }); + + it("omitted / non-positive staleAfterMs preserves today's 45-minute default exactly (#6594)", () => { + const lastRegatedAt = new Date(NOW - (SWEEP_STALENESS_THRESHOLD_MS + 1000)).toISOString(); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt, nowMs: NOW })).toBe(true); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt, nowMs: NOW, staleAfterMs: 0 })).toBe(true); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt, nowMs: NOW, staleAfterMs: -1 })).toBe(true); + expect(isSweepStale({ openPullRequestCount: 1, lastRegatedAt, nowMs: NOW, staleAfterMs: Number.NaN })).toBe(true); + }); +}); + +describe("resolveSweepStalenessThresholdMs (#6594)", () => { + it("returns the hardcoded default when the override is absent or minutes are null", () => { + expect(resolveSweepStalenessThresholdMs(undefined)).toBe(SWEEP_STALENESS_THRESHOLD_MS); + expect(resolveSweepStalenessThresholdMs({ present: false, enabled: false, staleAfterMinutes: null })).toBe( + SWEEP_STALENESS_THRESHOLD_MS, + ); + expect(resolveSweepStalenessThresholdMs({ present: true, enabled: true, staleAfterMinutes: null })).toBe( + SWEEP_STALENESS_THRESHOLD_MS, + ); + }); + + it("converts a present positive staleAfterMinutes to milliseconds", () => { + expect(resolveSweepStalenessThresholdMs({ present: true, enabled: true, staleAfterMinutes: 90 })).toBe(90 * 60_000); + }); }); describe("runSweepLivenessWatchdog (#audit-sweep-fanout-isolation follow-up)", () => { @@ -162,6 +240,44 @@ describe("runSweepLivenessWatchdog (#audit-sweep-fanout-isolation follow-up)", ( expect(sent).toEqual([expect.objectContaining({ type: "agent-regate-sweep", repoFullName: "owner/aged-repo" })]); }, 60_000); + it("threads a present staleAfterMinutes override into the stale decision (#6594)", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const start = new Date("2026-07-06T10:00:00.000Z"); + vi.setSystemTime(start); + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(m: import("../../src/types").JobMessage) { sent.push(m); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "override-repo", full_name: "owner/override-repo", private: false, owner: { login: "owner" } }, 9310); + await upsertRepositorySettings(env, { repoFullName: "owner/override-repo", autonomy: { merge: "auto" } }); + await upsertPullRequestFromGitHub(env, "owner/override-repo", { number: 1, title: "PR1", state: "open", user: { login: "c" }, head: { sha: "a1" }, labels: [], body: "" }); + await markPullRequestsRegated(env, "owner/override-repo", [1]); + // 15 minutes later: still inside the 45m default, but outside a 10m override. + vi.setSystemTime(new Date(start.getTime() + 15 * 60_000)); + + const notStaleUnderDefault = await runSweepLivenessWatchdog(env, { + present: true, + enabled: true, + staleAfterMinutes: null, + }); + expect(notStaleUnderDefault).toEqual([]); + + const staleUnderOverride = await runSweepLivenessWatchdog(env, { + present: true, + enabled: true, + staleAfterMinutes: 10, + }); + expect(staleUnderOverride).toEqual([ + expect.objectContaining({ + repoFullName: "owner/override-repo", + installationId: 9310, + openPullRequestCount: 1, + lastRegatedAt: start.toISOString(), + }), + ]); + expect(staleUnderOverride[0]?.ageMs).toBeGreaterThan(10 * 60_000); + expect(staleUnderOverride[0]?.ageMs).toBeLessThan(SWEEP_STALENESS_THRESHOLD_MS); + expect(sent).toEqual([expect.objectContaining({ type: "agent-regate-sweep", repoFullName: "owner/override-repo" })]); + }, 60_000); + it("watches an ALLOWLISTED (LOOPOVER_REVIEW_REPOS) installed repo even with no autonomy configured, and skips a plain repo that is neither allowlisted nor agent-configured", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({ From 746d54ecc0fe9f58c24d6aee8f414f14c6523f48 Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Thu, 16 Jul 2026 13:06:03 -0400 Subject: [PATCH 2/2] add blank line --- .loopover.yml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.loopover.yml.example b/.loopover.yml.example index ba33ebda42..9a477ceab1 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1285,6 +1285,7 @@ settings: # enabled: true # Bool. Default: false (the env var decides instead). # staleAfterMinutes: 90 # Optional positive int (#6594). Minutes without a sweep-marker advance before # # a repo with open PRs is treated as stale. Omit ⇒ hardcoded 45-minute default. + # Fleet-wide open-PR reconciliation cron (#6558 / #6275): config-as-code override for the # LOOPOVER_PR_RECONCILIATION flag that gates the fast open-PR reconciliation pass. Same shape and # precedence as `sweepWatchdog:` above. Distinct from the per-repo FORCE-OFF under `review.prReconciliation`.