diff --git a/packages/loopover-engine/src/config-lint.ts b/packages/loopover-engine/src/config-lint.ts index 3896341f5e..923e34fe7d 100644 --- a/packages/loopover-engine/src/config-lint.ts +++ b/packages/loopover-engine/src/config-lint.ts @@ -26,6 +26,7 @@ const TOP_LEVEL_FIELDS = [ "sweepWatchdog", "prReconciliation", "activeReviewReconciliation", + "loopEscalation", "federatedIntelligence", ] as const; diff --git a/packages/loopover-engine/src/focus-manifest-validation.ts b/packages/loopover-engine/src/focus-manifest-validation.ts index 7bb5e5bf10..09de925c0d 100644 --- a/packages/loopover-engine/src/focus-manifest-validation.ts +++ b/packages/loopover-engine/src/focus-manifest-validation.ts @@ -14,6 +14,7 @@ import { sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, + loopEscalationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, @@ -98,6 +99,8 @@ function focusManifestToNormalizedJson(manifest: FocusManifest): Record; + const enabled = normalizeOptionalBoolean(record.enabled, "loopEscalation.enabled", warnings) ?? false; + return { present: true, enabled }; +} + +/** Serialize a loopEscalation config back into the parse-compatible shape so a cached snapshot + * round-trips through {@link parseLoopEscalationConfig} unchanged. Returns null when nothing is + * configured. */ +export function loopEscalationConfigToJson(config: FocusManifestLoopEscalationConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled }; +} + /** * Parse the optional `federatedIntelligence:` mapping (#1970). Mirrors {@link parseUpstreamDriftIssuesConfig} * exactly -- `enabled` is the only field, defaulting to false, so the parsed value IS the effective value and @@ -3944,6 +3987,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): sweepWatchdog: parseSweepWatchdogConfig(record.sweepWatchdog, warnings), prReconciliation: parsePrReconciliationConfig(record.prReconciliation, warnings), activeReviewReconciliation: parseActiveReviewReconciliationConfig(record.activeReviewReconciliation, warnings), + loopEscalation: parseLoopEscalationConfig(record.loopEscalation, warnings), federatedIntelligence: parseFederatedIntelligenceConfig(record.federatedIntelligence, warnings), warnings, }; @@ -3971,6 +4015,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): !manifest.sweepWatchdog.present && !manifest.prReconciliation.present && !manifest.activeReviewReconciliation.present && + !manifest.loopEscalation.present && !manifest.federatedIntelligence.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); diff --git a/src/queue/job-dispatch.ts b/src/queue/job-dispatch.ts index ca08bf7ac1..9c99180152 100644 --- a/src/queue/job-dispatch.ts +++ b/src/queue/job-dispatch.ts @@ -26,7 +26,7 @@ import { executeAgentRun } from "../services/agent-orchestrator"; import { deliverNotification, evaluateNotificationEvent } from "../notifications/service"; import { isOpsEnabled, resolveOpsManifestOverride, runOpsAlerts } from "../review/ops-wire"; import { isSweepWatchdogEnabled, resolveSweepWatchdogManifestOverride, runSweepLivenessWatchdog } from "../review/sweep-watchdog"; -import { isLoopEscalationSweepEnabled, runLoopEscalationSweep } from "../review/loop-escalation-wire"; +import { isLoopEscalationSweepEnabled, resolveLoopEscalationManifestOverride, runLoopEscalationSweep } from "../review/loop-escalation-wire"; import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride, runOpenPrReconciliation } from "../review/pr-reconciliation"; import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride, runActiveReviewReconciliation } from "../review/active-review-reconciliation"; import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire"; @@ -315,10 +315,14 @@ export async function processJob(env: Env, message: JobMessage): Promise { } return; case "loop-escalation-sweep": - // Rent-a-Loop escalation (#6349, flag LOOPOVER_LOOP_ESCALATION). Defense-in-depth: the cron only - // ENQUEUES this when the flag is ON, but a stale in-flight job that lands after a flag-flip must still - // no-op. Fails safe internally — never throws into the queue. - if (isLoopEscalationSweepEnabled(env)) await runLoopEscalationSweep(env); + // Rent-a-Loop escalation (#6349, flag LOOPOVER_LOOP_ESCALATION, config-as-code override #8018). + // Defense-in-depth: the cron only ENQUEUES this when enabled, but a stale in-flight job that lands + // after a flag-flip (env OR manifest) must still no-op, so disabled does zero work here too. Fails + // safe internally — never throws into the queue. + { + const loopEscalationManifestOverride = await resolveLoopEscalationManifestOverride(env); + if (isLoopEscalationSweepEnabled(env, loopEscalationManifestOverride)) await runLoopEscalationSweep(env); + } return; case "reconcile-open-prs": // Self-heal (flag LOOPOVER_PR_RECONCILIATION). Defense-in-depth: the cron only ENQUEUES this when diff --git a/src/review/loop-escalation-wire.ts b/src/review/loop-escalation-wire.ts index 8fcb7da036..c318c334d9 100644 --- a/src/review/loop-escalation-wire.ts +++ b/src/review/loop-escalation-wire.ts @@ -21,6 +21,8 @@ import { type FleetLoopRow, } from "../../packages/loopover-engine/src/loop-fleet-summary"; import { countRecentAuditEventsForActorAndTarget, recordAuditEvent } from "../db/repositories"; +import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import { resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import { errorMessage } from "../utils/json"; const ALLOWED_DISCORD_HOSTS = new Set(["discord.com", "discordapp.com"]); @@ -28,11 +30,56 @@ const DEFAULT_COOLDOWN_MINUTES = 60; const AUDIT_EVENT_TYPE = "loop_escalation_notification.discord"; const AUDIT_TARGET_KEY = "fleet:loop-escalation"; -/** True when the scheduled fleet-escalation sweep is enabled. Default OFF. */ -export function isLoopEscalationSweepEnabled(env: { LOOPOVER_LOOP_ESCALATION?: string | undefined }): boolean { +/** A manifest-sourced enable override (#8018) -- the top-level `loopEscalation` block of the loopover + * self-repo's `.loopover.yml` (see FocusManifestLoopEscalationConfig). `present: false` means "no override + * configured", not "disabled" -- the caller falls through to the env var. Mirrors PrReconciliationManifestOverride. */ +export type LoopEscalationManifestOverride = { present: boolean; enabled: boolean }; + +/** True when the scheduled fleet-escalation sweep is enabled. Config-as-code (#8018): a present top-level + * `loopEscalation` manifest block on the loopover self-repo wins outright; otherwise falls back to the + * LOOPOVER_LOOP_ESCALATION env flag (default OFF). Flag-OFF (default) → the cron enqueues no sweep job and + * the queue processor no-ops on a stale in-flight one (defense-in-depth, mirrors isPrReconciliationEnabled). */ +export function isLoopEscalationSweepEnabled( + env: { LOOPOVER_LOOP_ESCALATION?: string | undefined }, + manifestOverride?: LoopEscalationManifestOverride | undefined, +): boolean { + if (manifestOverride?.present) return manifestOverride.enabled; return /^(1|true|yes|on)$/i.test((env.LOOPOVER_LOOP_ESCALATION ?? "").trim()); } +// Short in-isolate TTL cache for resolveLoopEscalationManifestOverride, mirroring ops-wire.ts / +// pr-reconciliation.ts: fleet-wide self-repo override, single slot, 60s TTL. +const LOOP_ESCALATION_MANIFEST_OVERRIDE_CACHE_TTL_MS = 60_000; +let loopEscalationManifestOverrideCache: { override: LoopEscalationManifestOverride; at: number } | null = null; + +/** + * Config-as-code override lookup (#8018): read the top-level `loopEscalation` block off the loopover + * self-repo's `.loopover.yml`. A manifest load failure degrades to `{ present: false }` so a hiccup can + * never accidentally enable or disable the sweep. `nowMs` defaults to `Date.now()` so callers need no + * change, while tests can pass a deterministic value to exercise the TTL precisely. + */ +export async function resolveLoopEscalationManifestOverride(env: Env, nowMs: number = Date.now()): Promise { + const hit = loopEscalationManifestOverrideCache; + if (hit && nowMs - hit.at < LOOP_ESCALATION_MANIFEST_OVERRIDE_CACHE_TTL_MS) return hit.override; + try { + const manifest = await loadRepoFocusManifest(env, resolveLoopOverSelfRepoFullName(env)); + const config = manifest.loopEscalation; + const override = { present: config.present, enabled: config.enabled }; + loopEscalationManifestOverrideCache = { override, at: nowMs }; + return override; + } catch (error) { + console.warn(JSON.stringify({ event: "loop_escalation_manifest_override_error", message: errorMessage(error).slice(0, 200) })); + const override = { present: false, enabled: false }; + loopEscalationManifestOverrideCache = { override, at: nowMs }; + return override; + } +} + +/** Test-only: clears the cached override, mirroring clearPrReconciliationManifestOverrideCacheForTest. */ +export function clearLoopEscalationManifestOverrideCacheForTest(): void { + loopEscalationManifestOverrideCache = null; +} + function envString(env: Env, name: string): string | undefined { const fromEnv = (env as unknown as Record)[name]; return typeof fromEnv === "string" && fromEnv.trim().length > 0 ? fromEnv.trim() : undefined; diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index f4297cfc38..21975d493a 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -2,7 +2,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import { mapWithConcurrency } from "../queue/map-with-concurrency"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, fairnessAnalyticsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; +import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, fairnessAnalyticsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, loopEscalationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; import { LOOPOVER_REPO_FOCUS_MANIFEST_YAML, resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import type { LocalManifestLoadResult } from "../selfhost/private-config"; @@ -335,6 +335,7 @@ function manifestToJson(manifest: FocusManifest): Record { sweepWatchdog: sweepWatchdogConfigToJson(manifest.sweepWatchdog), prReconciliation: prReconciliationConfigToJson(manifest.prReconciliation), activeReviewReconciliation: activeReviewReconciliationConfigToJson(manifest.activeReviewReconciliation), + loopEscalation: loopEscalationConfigToJson(manifest.loopEscalation), federatedIntelligence: federatedIntelligenceConfigToJson(manifest.federatedIntelligence), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 7c60840d0c..2fb63f6ea9 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -43,6 +43,7 @@ export { sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, + loopEscalationConfigToJson, federatedIntelligenceConfigToJson, FEDERATED_COLLECTOR_MODES, settingsOverrideToJson, @@ -79,6 +80,7 @@ export { type FocusManifestSweepWatchdogConfig, type FocusManifestPrReconciliationConfig, type FocusManifestActiveReviewReconciliationConfig, + type FocusManifestLoopEscalationConfig, type FocusManifestFederatedIntelligenceConfig, type FederatedCollectorMode, type FocusManifestSettings, diff --git a/test/unit/focus-manifest-validation.test.ts b/test/unit/focus-manifest-validation.test.ts index d82e8ebad9..d6ae97c862 100644 --- a/test/unit/focus-manifest-validation.test.ts +++ b/test/unit/focus-manifest-validation.test.ts @@ -116,6 +116,8 @@ prReconciliation: enabled: false activeReviewReconciliation: enabled: true +loopEscalation: + enabled: true `, }); expect(result.status).toBe("ok"); @@ -137,6 +139,7 @@ activeReviewReconciliation: sweepWatchdog: { enabled: true }, prReconciliation: { enabled: false }, activeReviewReconciliation: { enabled: true }, + loopEscalation: { enabled: true }, }); }); @@ -150,6 +153,7 @@ activeReviewReconciliation: expect(result.normalized).not.toHaveProperty("sweepWatchdog"); expect(result.normalized).not.toHaveProperty("prReconciliation"); expect(result.normalized).not.toHaveProperty("activeReviewReconciliation"); + expect(result.normalized).not.toHaveProperty("loopEscalation"); expect(result.normalized).not.toHaveProperty("federatedIntelligence"); }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index b5dfc82c18..5b2de4d0fe 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -48,6 +48,7 @@ import { sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, + loopEscalationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, @@ -959,6 +960,7 @@ describe("compileFocusManifestPolicy", () => { sweepWatchdog: { present: false, enabled: false, staleAfterMinutes: null }, prReconciliation: { present: false, enabled: false }, activeReviewReconciliation: { present: false, enabled: false }, + loopEscalation: { present: false, enabled: false }, federatedIntelligence: { present: false, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] }, warnings: [], }); @@ -2320,6 +2322,54 @@ describe("parseFocusManifest gate config", () => { }); }); + describe("loopEscalation: (#8018, Rent-a-Loop escalation sweep 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.loopEscalation).toEqual({ present: false, enabled: false }); + expect(m.present).toBe(false); + }); + + it("treats an explicit null the same as an omitted key", () => { + expect(parseFocusManifest({ loopEscalation: null }).loopEscalation).toEqual({ present: false, enabled: false }); + }); + + it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { + const asString = parseFocusManifest({ loopEscalation: "nope" as never }); + expect(asString.loopEscalation.present).toBe(false); + expect(asString.warnings.some((w) => /"loopEscalation" must be a mapping/.test(w))).toBe(true); + const asArray = parseFocusManifest({ loopEscalation: ["nope"] as never }); + expect(asArray.loopEscalation.present).toBe(false); + expect(asArray.warnings.some((w) => /"loopEscalation" must be a mapping/.test(w))).toBe(true); + }); + + it("parses enabled: true, making the manifest present", () => { + const m = parseFocusManifest({ loopEscalation: { enabled: true } }); + expect(m.loopEscalation).toEqual({ present: true, enabled: true }); + expect(m.present).toBe(true); + }); + + it("parses enabled: false explicitly, still marking the manifest present (present is a real override, off)", () => { + const m = parseFocusManifest({ loopEscalation: { enabled: false } }); + expect(m.loopEscalation).toEqual({ present: true, enabled: false }); + expect(m.present).toBe(true); + }); + + it("warns and defaults to false when enabled is a non-boolean value", () => { + const m = parseFocusManifest({ loopEscalation: { enabled: "yes" as unknown as boolean } }); + expect(m.loopEscalation.enabled).toBe(false); + expect(m.warnings.some((w) => /loopEscalation\.enabled/.test(w))).toBe(true); + }); + + it("round-trips through loopEscalationConfigToJson → parseFocusManifest unchanged", () => { + const m = parseFocusManifest({ loopEscalation: { enabled: true } }); + expect(parseFocusManifest({ loopEscalation: loopEscalationConfigToJson(m.loopEscalation) }).loopEscalation).toEqual(m.loopEscalation); + }); + + it("loopEscalationConfigToJson returns null for an absent config", () => { + expect(loopEscalationConfigToJson(parseFocusManifest(null).loopEscalation)).toBeNull(); + }); + }); + describe("federatedIntelligence: (#1970, opt-in federated fleet intelligence export config-as-code toggle)", () => { it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => { const m = parseFocusManifest({}); diff --git a/test/unit/loop-escalation-wire.test.ts b/test/unit/loop-escalation-wire.test.ts index 3b1aa031af..405157e6e6 100644 --- a/test/unit/loop-escalation-wire.test.ts +++ b/test/unit/loop-escalation-wire.test.ts @@ -1,13 +1,18 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as repositories from "../../src/db/repositories"; import { + clearLoopEscalationManifestOverrideCacheForTest, isLoopEscalationSweepEnabled, loadActiveLoopsFromEnv, parseActiveLoopFacts, + resolveLoopEscalationManifestOverride, runLoopEscalationSweep, } from "../../src/review/loop-escalation-wire"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; +const SELF_REPO = "JSONbored/loopover"; + describe("isLoopEscalationSweepEnabled (#6349)", () => { it("defaults OFF and accepts the standard truthy env forms", () => { for (const off of [undefined, "", "false", "no", "0", "off"]) { @@ -17,6 +22,77 @@ describe("isLoopEscalationSweepEnabled (#6349)", () => { expect(isLoopEscalationSweepEnabled({ LOOPOVER_LOOP_ESCALATION: on })).toBe(true); } }); + + it("a present manifest override wins outright over the env flag, in both directions (#8018)", () => { + expect(isLoopEscalationSweepEnabled({ LOOPOVER_LOOP_ESCALATION: "false" }, { present: true, enabled: true })).toBe(true); + expect(isLoopEscalationSweepEnabled({ LOOPOVER_LOOP_ESCALATION: "true" }, { present: true, enabled: false })).toBe(false); + }); + + it("falls back to the env flag when the manifest override is not present", () => { + expect(isLoopEscalationSweepEnabled({ LOOPOVER_LOOP_ESCALATION: "true" }, { present: false, enabled: false })).toBe(true); + expect(isLoopEscalationSweepEnabled({ LOOPOVER_LOOP_ESCALATION: "false" }, undefined)).toBe(false); + }); +}); + +describe("resolveLoopEscalationManifestOverride — config-as-code lookup (#8018)", () => { + beforeEach(() => { + clearLoopEscalationManifestOverrideCacheForTest(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns the self-repo's configured loopEscalation block when present", async () => { + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); + await upsertRepoFocusManifest(env, SELF_REPO, { loopEscalation: { enabled: true } }); + + expect(await resolveLoopEscalationManifestOverride(env)).toEqual({ present: true, enabled: true }); + }); + + it("returns present: false when the self-repo has no loopEscalation block configured", async () => { + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); + await upsertRepoFocusManifest(env, SELF_REPO, { wantedPaths: ["src/"] }); + + expect(await resolveLoopEscalationManifestOverride(env)).toEqual({ present: false, enabled: false }); + }); + + it("degrades to present: false (never throws) when the manifest load itself fails", async () => { + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + if (/"signal_snapshots"|signal_snapshots/i.test(sql)) throw new Error("poisoned query"); + return realPrepare(sql); + }) as typeof env.DB.prepare; + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const warnings = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(await resolveLoopEscalationManifestOverride(env)).toEqual({ present: false, enabled: false }); + expect(warnings.mock.calls.map((c) => String(c[0])).some((line) => line.includes("loop_escalation_manifest_override_error"))).toBe(true); + }); + + it("within the 60s TTL, reuses the cached override instead of re-reading the manifest", async () => { + const env = createTestEnv({ LOOPOVER_DRIFT_ISSUE_REPO: SELF_REPO }); + await upsertRepoFocusManifest(env, SELF_REPO, { loopEscalation: { enabled: true } }); + const t0 = Date.parse("2026-07-22T00:00:00Z"); + expect(await resolveLoopEscalationManifestOverride(env, t0)).toEqual({ present: true, enabled: true }); + + env.DB.prepare = (() => { + throw new Error("should not be queried on a cache hit"); + }) as typeof env.DB.prepare; + expect(await resolveLoopEscalationManifestOverride(env, t0 + 30_000)).toEqual({ present: true, enabled: true }); + }); + + 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, { loopEscalation: { enabled: true } }); + const t0 = Date.parse("2026-07-22T00:00:00Z"); + expect(await resolveLoopEscalationManifestOverride(env, t0)).toEqual({ present: true, enabled: true }); + + await upsertRepoFocusManifest(env, SELF_REPO, { loopEscalation: { enabled: false } }); + expect(await resolveLoopEscalationManifestOverride(env, t0 + 60_001)).toEqual({ present: true, enabled: false }); + }); }); describe("parseActiveLoopFacts / loadActiveLoopsFromEnv (#6349)", () => { diff --git a/test/unit/queue-5.test.ts b/test/unit/queue-5.test.ts index 9d535ba932..8a3e95118b 100644 --- a/test/unit/queue-5.test.ts +++ b/test/unit/queue-5.test.ts @@ -3,6 +3,7 @@ import { generateKeyPairSync } from "node:crypto"; import { clearInstallationTokenCacheForTest } from "../../src/github/app"; import { clearReviewSuppressionCacheForTest } from "../../src/review/review-memory-wire"; import { clearOpsManifestOverrideCacheForTest } from "../../src/review/ops-wire"; +import { clearLoopEscalationManifestOverrideCacheForTest } from "../../src/review/loop-escalation-wire"; import { PR_PANEL_COMMENT_MARKER } from "../../src/github/comments"; import * as backfillModule from "../../src/github/backfill"; import * as rateLimitModule from "../../src/github/rate-limit"; @@ -224,6 +225,7 @@ describe("queue processors", () => { clearInstallationTokenCacheForTest(); clearReviewSuppressionCacheForTest(); clearOpsManifestOverrideCacheForTest(); + clearLoopEscalationManifestOverrideCacheForTest(); vi.mocked(fetchPullRequestFreshness).mockReset(); vi.mocked(fetchPullRequestFreshness).mockImplementation(async (_env, args) => ({ status: "current", @@ -5860,6 +5862,35 @@ describe("queue processors", () => { errorSpy.mockRestore(); }); + it("loop-escalation-sweep job no-ops when the self-repo manifest disables it, even with the env flag ON (#8018)", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const env = createTestEnv({ + LOOPOVER_LOOP_ESCALATION: "true", + LOOPOVER_DRIFT_ISSUE_REPO: "JSONbored/loopover", + LOOPOVER_ACTIVE_LOOPS_JSON: JSON.stringify([{ loopId: "broken", tenantId: "acme", runStatus: "abandoned" }]), + }); + await upsertRepoFocusManifest(env, "JSONbored/loopover", { loopEscalation: { enabled: false } }); + + await processJob(env, { type: "loop-escalation-sweep", requestedBy: "test" }); + + expect(errorSpy.mock.calls.map((c) => String(c[0])).some((line) => line.includes("loop_escalation_needs_attention"))).toBe(false); + errorSpy.mockRestore(); + }); + + it("loop-escalation-sweep job runs when the self-repo manifest enables it, even with the env flag OFF (#8018)", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const env = createTestEnv({ + LOOPOVER_DRIFT_ISSUE_REPO: "JSONbored/loopover", + LOOPOVER_ACTIVE_LOOPS_JSON: JSON.stringify([{ loopId: "broken", tenantId: "acme", runStatus: "abandoned" }]), + }); + await upsertRepoFocusManifest(env, "JSONbored/loopover", { loopEscalation: { enabled: true } }); + + await processJob(env, { type: "loop-escalation-sweep", requestedBy: "test" }); + + expect(errorSpy.mock.calls.map((c) => String(c[0])).some((line) => line.includes("loop_escalation_needs_attention"))).toBe(true); + errorSpy.mockRestore(); + }); + it("reconcile-open-prs job no-ops when LOOPOVER_PR_RECONCILIATION is OFF (does no scan)", async () => { const env = createTestEnv(); // flag unset → OFF await upsertRepositoryFromGitHub(env, { name: "stale-repo", full_name: "owner/stale-repo", private: false, owner: { login: "owner" } }, 9410);