Skip to content

Commit f3cb090

Browse files
authored
feat(review): config-as-code manifest override for the loop-escalation sweep cron (#8018) (#8059)
loop-escalation-sweep was the only flag-gated cron job in job-dispatch's switch without a manifest-override re-check: six siblings (ops-alerts, sweep-liveness-watchdog, reconcile-open-prs, reconcile-active-review-tracking, generate-maintainer-recap, rag-index-repo) resolve a resolveXManifestOverride(env) and pass it into isXEnabled(env, override) so a stale in-flight job that lands after a .loopover.yml-based flag flip still no-ops -- but an operator disabling Rent-a-Loop escalation via .loopover.yml (rather than LOOPOVER_LOOP_ESCALATION) could not stop an already-enqueued sweep job. The capability was never built when #6349 added the sweep. Mirror the siblings end to end: a top-level `loopEscalation:` manifest block ({present, enabled}, parse/serialize/allowlist shaped exactly like prReconciliation's), resolveLoopEscalationManifestOverride in loop-escalation-wire.ts (60s single-slot TTL cache + fail-safe degrade to present:false, mirroring pr-reconciliation.ts), isLoopEscalationSweepEnabled honoring the override (present wins outright, else env fallback), and the dispatch case resolving + passing it like its six siblings. Tests mirror each sibling's: the full parse/round-trip suite for the new manifest block, resolver present/absent/failure/TTL cases, override-precedence on isLoopEscalationSweepEnabled, and both dispatch directions (manifest disables despite env ON; manifest enables despite env OFF).
1 parent 17848e5 commit f3cb090

11 files changed

Lines changed: 273 additions & 9 deletions

packages/loopover-engine/src/config-lint.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const TOP_LEVEL_FIELDS = [
2626
"sweepWatchdog",
2727
"prReconciliation",
2828
"activeReviewReconciliation",
29+
"loopEscalation",
2930
"federatedIntelligence",
3031
] as const;
3132

packages/loopover-engine/src/focus-manifest-validation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
sweepWatchdogConfigToJson,
1515
prReconciliationConfigToJson,
1616
activeReviewReconciliationConfigToJson,
17+
loopEscalationConfigToJson,
1718
federatedIntelligenceConfigToJson,
1819
settingsOverrideToJson,
1920
type FocusManifest,
@@ -98,6 +99,8 @@ function focusManifestToNormalizedJson(manifest: FocusManifest): Record<string,
9899
if (prReconciliation !== null) normalized.prReconciliation = prReconciliation;
99100
const activeReviewReconciliation = activeReviewReconciliationConfigToJson(manifest.activeReviewReconciliation);
100101
if (activeReviewReconciliation !== null) normalized.activeReviewReconciliation = activeReviewReconciliation;
102+
const loopEscalation = loopEscalationConfigToJson(manifest.loopEscalation);
103+
if (loopEscalation !== null) normalized.loopEscalation = loopEscalation;
101104
const federatedIntelligence = federatedIntelligenceConfigToJson(manifest.federatedIntelligence);
102105
if (federatedIntelligence !== null) normalized.federatedIntelligence = federatedIntelligence;
103106

packages/loopover-engine/src/focus-manifest.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,18 @@ export type FocusManifestActiveReviewReconciliationConfig = {
508508
enabled: boolean;
509509
};
510510

511+
/**
512+
* Config-as-code override for the fleet-wide Rent-a-Loop escalation sweep cron
513+
* (LOOPOVER_LOOP_ESCALATION), declared under top-level `loopEscalation:` (#8018). Same shape and
514+
* precedence as `prReconciliation:` above. The capability was never built when #6349 added the sweep,
515+
* leaving it the only flag-gated cron in job-dispatch's switch without a manifest override.
516+
* Not present ⇒ the caller falls back to the LOOPOVER_LOOP_ESCALATION env var.
517+
*/
518+
export type FocusManifestLoopEscalationConfig = {
519+
present: boolean;
520+
enabled: boolean;
521+
};
522+
511523
/**
512524
* Config-as-code opt-in for the federated fleet intelligence export (#1970), declared under
513525
* `federatedIntelligence:`. Gates buildFederatedBundle (src/orb/federated-bundle.ts), which packages this
@@ -1217,6 +1229,7 @@ export type FocusManifest = {
12171229
sweepWatchdog: FocusManifestSweepWatchdogConfig;
12181230
prReconciliation: FocusManifestPrReconciliationConfig;
12191231
activeReviewReconciliation: FocusManifestActiveReviewReconciliationConfig;
1232+
loopEscalation: FocusManifestLoopEscalationConfig;
12201233
federatedIntelligence: FocusManifestFederatedIntelligenceConfig;
12211234
warnings: string[];
12221235
};
@@ -1402,6 +1415,11 @@ const EMPTY_ACTIVE_REVIEW_RECONCILIATION_CONFIG: FocusManifestActiveReviewReconc
14021415
enabled: false,
14031416
};
14041417

1418+
const EMPTY_LOOP_ESCALATION_CONFIG: FocusManifestLoopEscalationConfig = {
1419+
present: false,
1420+
enabled: false,
1421+
};
1422+
14051423
const EMPTY_FEDERATED_INTELLIGENCE_CONFIG: FocusManifestFederatedIntelligenceConfig = {
14061424
present: false,
14071425
enabled: false,
@@ -1437,6 +1455,7 @@ const EMPTY_MANIFEST: FocusManifest = {
14371455
sweepWatchdog: { ...EMPTY_SWEEP_WATCHDOG_CONFIG },
14381456
prReconciliation: { ...EMPTY_PR_RECONCILIATION_CONFIG },
14391457
activeReviewReconciliation: { ...EMPTY_ACTIVE_REVIEW_RECONCILIATION_CONFIG },
1458+
loopEscalation: { ...EMPTY_LOOP_ESCALATION_CONFIG },
14401459
federatedIntelligence: { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG },
14411460
warnings: [],
14421461
};
@@ -1478,6 +1497,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo
14781497
sweepWatchdog: { ...EMPTY_SWEEP_WATCHDOG_CONFIG },
14791498
prReconciliation: { ...EMPTY_PR_RECONCILIATION_CONFIG },
14801499
activeReviewReconciliation: { ...EMPTY_ACTIVE_REVIEW_RECONCILIATION_CONFIG },
1500+
loopEscalation: { ...EMPTY_LOOP_ESCALATION_CONFIG },
14811501
federatedIntelligence: { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG },
14821502
};
14831503
}
@@ -2381,6 +2401,29 @@ export function activeReviewReconciliationConfigToJson(config: FocusManifestActi
23812401
return { enabled: config.enabled };
23822402
}
23832403

2404+
/**
2405+
* Parse the optional top-level `loopEscalation:` mapping (#8018). Mirrors
2406+
* {@link parsePrReconciliationConfig} exactly — `enabled` is the only field.
2407+
*/
2408+
function parseLoopEscalationConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestLoopEscalationConfig {
2409+
if (value === undefined || value === null) return { ...EMPTY_LOOP_ESCALATION_CONFIG };
2410+
if (typeof value !== "object" || Array.isArray(value)) {
2411+
warnings.push('Manifest field "loopEscalation" must be a mapping; ignoring it.');
2412+
return { ...EMPTY_LOOP_ESCALATION_CONFIG };
2413+
}
2414+
const record = value as Record<string, JsonValue>;
2415+
const enabled = normalizeOptionalBoolean(record.enabled, "loopEscalation.enabled", warnings) ?? false;
2416+
return { present: true, enabled };
2417+
}
2418+
2419+
/** Serialize a loopEscalation config back into the parse-compatible shape so a cached snapshot
2420+
* round-trips through {@link parseLoopEscalationConfig} unchanged. Returns null when nothing is
2421+
* configured. */
2422+
export function loopEscalationConfigToJson(config: FocusManifestLoopEscalationConfig): JsonValue {
2423+
if (!config.present) return null;
2424+
return { enabled: config.enabled };
2425+
}
2426+
23842427
/**
23852428
* Parse the optional `federatedIntelligence:` mapping (#1970). Mirrors {@link parseUpstreamDriftIssuesConfig}
23862429
* 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):
39443987
sweepWatchdog: parseSweepWatchdogConfig(record.sweepWatchdog, warnings),
39453988
prReconciliation: parsePrReconciliationConfig(record.prReconciliation, warnings),
39463989
activeReviewReconciliation: parseActiveReviewReconciliationConfig(record.activeReviewReconciliation, warnings),
3990+
loopEscalation: parseLoopEscalationConfig(record.loopEscalation, warnings),
39473991
federatedIntelligence: parseFederatedIntelligenceConfig(record.federatedIntelligence, warnings),
39483992
warnings,
39493993
};
@@ -3971,6 +4015,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource):
39714015
!manifest.sweepWatchdog.present &&
39724016
!manifest.prReconciliation.present &&
39734017
!manifest.activeReviewReconciliation.present &&
4018+
!manifest.loopEscalation.present &&
39744019
!manifest.federatedIntelligence.present
39754020
) {
39764021
warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals.");

src/queue/job-dispatch.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { executeAgentRun } from "../services/agent-orchestrator";
2626
import { deliverNotification, evaluateNotificationEvent } from "../notifications/service";
2727
import { isOpsEnabled, resolveOpsManifestOverride, runOpsAlerts } from "../review/ops-wire";
2828
import { isSweepWatchdogEnabled, resolveSweepWatchdogManifestOverride, runSweepLivenessWatchdog } from "../review/sweep-watchdog";
29-
import { isLoopEscalationSweepEnabled, runLoopEscalationSweep } from "../review/loop-escalation-wire";
29+
import { isLoopEscalationSweepEnabled, resolveLoopEscalationManifestOverride, runLoopEscalationSweep } from "../review/loop-escalation-wire";
3030
import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride, runOpenPrReconciliation } from "../review/pr-reconciliation";
3131
import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride, runActiveReviewReconciliation } from "../review/active-review-reconciliation";
3232
import { isSelfTuneEnabled, runSelfTune } from "../review/selftune-wire";
@@ -315,10 +315,14 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
315315
}
316316
return;
317317
case "loop-escalation-sweep":
318-
// Rent-a-Loop escalation (#6349, flag LOOPOVER_LOOP_ESCALATION). Defense-in-depth: the cron only
319-
// ENQUEUES this when the flag is ON, but a stale in-flight job that lands after a flag-flip must still
320-
// no-op. Fails safe internally — never throws into the queue.
321-
if (isLoopEscalationSweepEnabled(env)) await runLoopEscalationSweep(env);
318+
// Rent-a-Loop escalation (#6349, flag LOOPOVER_LOOP_ESCALATION, config-as-code override #8018).
319+
// Defense-in-depth: the cron only ENQUEUES this when enabled, but a stale in-flight job that lands
320+
// after a flag-flip (env OR manifest) must still no-op, so disabled does zero work here too. Fails
321+
// safe internally — never throws into the queue.
322+
{
323+
const loopEscalationManifestOverride = await resolveLoopEscalationManifestOverride(env);
324+
if (isLoopEscalationSweepEnabled(env, loopEscalationManifestOverride)) await runLoopEscalationSweep(env);
325+
}
322326
return;
323327
case "reconcile-open-prs":
324328
// Self-heal (flag LOOPOVER_PR_RECONCILIATION). Defense-in-depth: the cron only ENQUEUES this when

src/review/loop-escalation-wire.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,65 @@ import {
2121
type FleetLoopRow,
2222
} from "../../packages/loopover-engine/src/loop-fleet-summary";
2323
import { countRecentAuditEventsForActorAndTarget, recordAuditEvent } from "../db/repositories";
24+
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
25+
import { resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest";
2426
import { errorMessage } from "../utils/json";
2527

2628
const ALLOWED_DISCORD_HOSTS = new Set(["discord.com", "discordapp.com"]);
2729
const DEFAULT_COOLDOWN_MINUTES = 60;
2830
const AUDIT_EVENT_TYPE = "loop_escalation_notification.discord";
2931
const AUDIT_TARGET_KEY = "fleet:loop-escalation";
3032

31-
/** True when the scheduled fleet-escalation sweep is enabled. Default OFF. */
32-
export function isLoopEscalationSweepEnabled(env: { LOOPOVER_LOOP_ESCALATION?: string | undefined }): boolean {
33+
/** A manifest-sourced enable override (#8018) -- the top-level `loopEscalation` block of the loopover
34+
* self-repo's `.loopover.yml` (see FocusManifestLoopEscalationConfig). `present: false` means "no override
35+
* configured", not "disabled" -- the caller falls through to the env var. Mirrors PrReconciliationManifestOverride. */
36+
export type LoopEscalationManifestOverride = { present: boolean; enabled: boolean };
37+
38+
/** True when the scheduled fleet-escalation sweep is enabled. Config-as-code (#8018): a present top-level
39+
* `loopEscalation` manifest block on the loopover self-repo wins outright; otherwise falls back to the
40+
* LOOPOVER_LOOP_ESCALATION env flag (default OFF). Flag-OFF (default) → the cron enqueues no sweep job and
41+
* the queue processor no-ops on a stale in-flight one (defense-in-depth, mirrors isPrReconciliationEnabled). */
42+
export function isLoopEscalationSweepEnabled(
43+
env: { LOOPOVER_LOOP_ESCALATION?: string | undefined },
44+
manifestOverride?: LoopEscalationManifestOverride | undefined,
45+
): boolean {
46+
if (manifestOverride?.present) return manifestOverride.enabled;
3347
return /^(1|true|yes|on)$/i.test((env.LOOPOVER_LOOP_ESCALATION ?? "").trim());
3448
}
3549

50+
// Short in-isolate TTL cache for resolveLoopEscalationManifestOverride, mirroring ops-wire.ts /
51+
// pr-reconciliation.ts: fleet-wide self-repo override, single slot, 60s TTL.
52+
const LOOP_ESCALATION_MANIFEST_OVERRIDE_CACHE_TTL_MS = 60_000;
53+
let loopEscalationManifestOverrideCache: { override: LoopEscalationManifestOverride; at: number } | null = null;
54+
55+
/**
56+
* Config-as-code override lookup (#8018): read the top-level `loopEscalation` block off the loopover
57+
* self-repo's `.loopover.yml`. A manifest load failure degrades to `{ present: false }` so a hiccup can
58+
* never accidentally enable or disable the sweep. `nowMs` defaults to `Date.now()` so callers need no
59+
* change, while tests can pass a deterministic value to exercise the TTL precisely.
60+
*/
61+
export async function resolveLoopEscalationManifestOverride(env: Env, nowMs: number = Date.now()): Promise<LoopEscalationManifestOverride> {
62+
const hit = loopEscalationManifestOverrideCache;
63+
if (hit && nowMs - hit.at < LOOP_ESCALATION_MANIFEST_OVERRIDE_CACHE_TTL_MS) return hit.override;
64+
try {
65+
const manifest = await loadRepoFocusManifest(env, resolveLoopOverSelfRepoFullName(env));
66+
const config = manifest.loopEscalation;
67+
const override = { present: config.present, enabled: config.enabled };
68+
loopEscalationManifestOverrideCache = { override, at: nowMs };
69+
return override;
70+
} catch (error) {
71+
console.warn(JSON.stringify({ event: "loop_escalation_manifest_override_error", message: errorMessage(error).slice(0, 200) }));
72+
const override = { present: false, enabled: false };
73+
loopEscalationManifestOverrideCache = { override, at: nowMs };
74+
return override;
75+
}
76+
}
77+
78+
/** Test-only: clears the cached override, mirroring clearPrReconciliationManifestOverrideCacheForTest. */
79+
export function clearLoopEscalationManifestOverrideCacheForTest(): void {
80+
loopEscalationManifestOverrideCache = null;
81+
}
82+
3683
function envString(env: Env, name: string): string | undefined {
3784
const fromEnv = (env as unknown as Record<string, unknown>)[name];
3885
return typeof fromEnv === "string" && fromEnv.trim().length > 0 ? fromEnv.trim() : undefined;

src/signals/focus-manifest-loader.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories";
22
import { mapWithConcurrency } from "../queue/map-with-concurrency";
33
import type { JsonValue } from "../types";
44
import { nowIso } from "../utils/json";
5-
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";
5+
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";
66
import { LOOPOVER_REPO_FOCUS_MANIFEST_YAML, resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest";
77
import type { LocalManifestLoadResult } from "../selfhost/private-config";
88

@@ -335,6 +335,7 @@ function manifestToJson(manifest: FocusManifest): Record<string, JsonValue> {
335335
sweepWatchdog: sweepWatchdogConfigToJson(manifest.sweepWatchdog),
336336
prReconciliation: prReconciliationConfigToJson(manifest.prReconciliation),
337337
activeReviewReconciliation: activeReviewReconciliationConfigToJson(manifest.activeReviewReconciliation),
338+
loopEscalation: loopEscalationConfigToJson(manifest.loopEscalation),
338339
federatedIntelligence: federatedIntelligenceConfigToJson(manifest.federatedIntelligence),
339340
};
340341
}

src/signals/focus-manifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export {
4343
sweepWatchdogConfigToJson,
4444
prReconciliationConfigToJson,
4545
activeReviewReconciliationConfigToJson,
46+
loopEscalationConfigToJson,
4647
federatedIntelligenceConfigToJson,
4748
FEDERATED_COLLECTOR_MODES,
4849
settingsOverrideToJson,
@@ -79,6 +80,7 @@ export {
7980
type FocusManifestSweepWatchdogConfig,
8081
type FocusManifestPrReconciliationConfig,
8182
type FocusManifestActiveReviewReconciliationConfig,
83+
type FocusManifestLoopEscalationConfig,
8284
type FocusManifestFederatedIntelligenceConfig,
8385
type FederatedCollectorMode,
8486
type FocusManifestSettings,

test/unit/focus-manifest-validation.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ prReconciliation:
116116
enabled: false
117117
activeReviewReconciliation:
118118
enabled: true
119+
loopEscalation:
120+
enabled: true
119121
`,
120122
});
121123
expect(result.status).toBe("ok");
@@ -137,6 +139,7 @@ activeReviewReconciliation:
137139
sweepWatchdog: { enabled: true },
138140
prReconciliation: { enabled: false },
139141
activeReviewReconciliation: { enabled: true },
142+
loopEscalation: { enabled: true },
140143
});
141144
});
142145

@@ -150,6 +153,7 @@ activeReviewReconciliation:
150153
expect(result.normalized).not.toHaveProperty("sweepWatchdog");
151154
expect(result.normalized).not.toHaveProperty("prReconciliation");
152155
expect(result.normalized).not.toHaveProperty("activeReviewReconciliation");
156+
expect(result.normalized).not.toHaveProperty("loopEscalation");
153157
expect(result.normalized).not.toHaveProperty("federatedIntelligence");
154158
});
155159

test/unit/focus-manifest.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
sweepWatchdogConfigToJson,
4949
prReconciliationConfigToJson,
5050
activeReviewReconciliationConfigToJson,
51+
loopEscalationConfigToJson,
5152
federatedIntelligenceConfigToJson,
5253
settingsOverrideToJson,
5354
type FocusManifest,
@@ -959,6 +960,7 @@ describe("compileFocusManifestPolicy", () => {
959960
sweepWatchdog: { present: false, enabled: false, staleAfterMinutes: null },
960961
prReconciliation: { present: false, enabled: false },
961962
activeReviewReconciliation: { present: false, enabled: false },
963+
loopEscalation: { present: false, enabled: false },
962964
federatedIntelligence: { present: false, enabled: false, collectorUrl: null, collectorMode: null, peerKeys: [] },
963965
warnings: [],
964966
});
@@ -2320,6 +2322,54 @@ describe("parseFocusManifest gate config", () => {
23202322
});
23212323
});
23222324

2325+
describe("loopEscalation: (#8018, Rent-a-Loop escalation sweep config-as-code override)", () => {
2326+
it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => {
2327+
const m = parseFocusManifest({});
2328+
expect(m.loopEscalation).toEqual({ present: false, enabled: false });
2329+
expect(m.present).toBe(false);
2330+
});
2331+
2332+
it("treats an explicit null the same as an omitted key", () => {
2333+
expect(parseFocusManifest({ loopEscalation: null }).loopEscalation).toEqual({ present: false, enabled: false });
2334+
});
2335+
2336+
it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => {
2337+
const asString = parseFocusManifest({ loopEscalation: "nope" as never });
2338+
expect(asString.loopEscalation.present).toBe(false);
2339+
expect(asString.warnings.some((w) => /"loopEscalation" must be a mapping/.test(w))).toBe(true);
2340+
const asArray = parseFocusManifest({ loopEscalation: ["nope"] as never });
2341+
expect(asArray.loopEscalation.present).toBe(false);
2342+
expect(asArray.warnings.some((w) => /"loopEscalation" must be a mapping/.test(w))).toBe(true);
2343+
});
2344+
2345+
it("parses enabled: true, making the manifest present", () => {
2346+
const m = parseFocusManifest({ loopEscalation: { enabled: true } });
2347+
expect(m.loopEscalation).toEqual({ present: true, enabled: true });
2348+
expect(m.present).toBe(true);
2349+
});
2350+
2351+
it("parses enabled: false explicitly, still marking the manifest present (present is a real override, off)", () => {
2352+
const m = parseFocusManifest({ loopEscalation: { enabled: false } });
2353+
expect(m.loopEscalation).toEqual({ present: true, enabled: false });
2354+
expect(m.present).toBe(true);
2355+
});
2356+
2357+
it("warns and defaults to false when enabled is a non-boolean value", () => {
2358+
const m = parseFocusManifest({ loopEscalation: { enabled: "yes" as unknown as boolean } });
2359+
expect(m.loopEscalation.enabled).toBe(false);
2360+
expect(m.warnings.some((w) => /loopEscalation\.enabled/.test(w))).toBe(true);
2361+
});
2362+
2363+
it("round-trips through loopEscalationConfigToJson → parseFocusManifest unchanged", () => {
2364+
const m = parseFocusManifest({ loopEscalation: { enabled: true } });
2365+
expect(parseFocusManifest({ loopEscalation: loopEscalationConfigToJson(m.loopEscalation) }).loopEscalation).toEqual(m.loopEscalation);
2366+
});
2367+
2368+
it("loopEscalationConfigToJson returns null for an absent config", () => {
2369+
expect(loopEscalationConfigToJson(parseFocusManifest(null).loopEscalation)).toBeNull();
2370+
});
2371+
});
2372+
23232373
describe("federatedIntelligence: (#1970, opt-in federated fleet intelligence export config-as-code toggle)", () => {
23242374
it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => {
23252375
const m = parseFocusManifest({});

0 commit comments

Comments
 (0)