From f2d2c5c8756fd3082d7f4fb2653be377fad797d3 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:24:35 -0700 Subject: [PATCH] feat(agent): scheduled re-gate sweep (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Phase 0 (#768) with the scheduling rail. GitHub fires no per-PR event when a base branch advances (or a sibling duplicate merges, or the focus manifest / settings change), so an open PR's gate verdict can drift stale with nothing to refresh it. An hourly cron sweep recomputes those verdicts. - index.ts: hourly `agent-regate-sweep` fan-out job (the 30-min cron's top-of- hour tick). - processors.ts: fanOutAgentRegateSweepJobs enqueues one per-repo sweep for every repo with an acting autonomy level (isAgentConfigured); sweepRepoRegate recomputes the DETERMINISTIC gate verdict for that repo's stalest open PRs and records an `agent.sweep.regate` audit event. ADVISORY ONLY — nothing is published to GitHub and no PR is mutated; #778 turns flagged verdicts into actions later. Respects the #776 kill-switch: a global or per-repo pause records a skip and recomputes nothing. - settings/agent-sweep.ts: pure selectRegateCandidates — drops drafts + PRs fresh within the hour (already gated by their webhook), takes the 25 stalest. Idempotent + rate-aware. - settings/autonomy.ts: isAgentConfigured (any acting action class). No new repo setting, migration, or config-as-code surface — reuses the autonomy/agentPaused/agentDryRun config from #773/#774/#776. Tests: agent-sweep selection (incl. unparseable-now + caps), isAgentConfigured, and 5 processor cases (fan-out filtering, advisory recompute with flagged + clean verdicts, kill-switch skip, defensive no-ops, quiet-when-fresh). New code 100% covered; full suite green (2037). --- src/index.ts | 3 + src/queue/processors.ts | 83 ++++++++++++++++++++++++++ src/settings/agent-sweep.ts | 45 +++++++++++++++ src/settings/autonomy.ts | 9 +++ src/types.ts | 7 +++ test/unit/agent-sweep.test.ts | 72 +++++++++++++++++++++++ test/unit/autonomy.test.ts | 16 +++++ test/unit/index.test.ts | 2 + test/unit/queue.test.ts | 106 ++++++++++++++++++++++++++++++++++ 9 files changed, 343 insertions(+) create mode 100644 src/settings/agent-sweep.ts create mode 100644 test/unit/agent-sweep.test.ts diff --git a/src/index.ts b/src/index.ts index 9d2692fc2e..b28d4e6040 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,9 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): jobs.push({ type: "refresh-scoring-model", requestedBy: "schedule" }); jobs.push({ type: "refresh-upstream-drift", requestedBy: "schedule" }); jobs.push({ type: "rollup-product-usage", requestedBy: "schedule", days: 7 }); + // Agent layer (#777): re-gate stale open PRs hourly. Fans out to one job per agent-configured repo; + // webhooks don't fire when a PR's base advances, so this is what keeps those verdicts fresh. + jobs.push({ type: "agent-regate-sweep", requestedBy: "schedule" }); } if (isHourly && scheduledAt.getUTCDay() === 1 && hour === 12) { jobs.push({ type: "generate-weekly-value-report", requestedBy: "schedule", variant: "operator", days: 7 }); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 8d13bef8db..6656e9b1c3 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -96,6 +96,9 @@ import { import { executeAgentRun, explainBlockersWithAgent, planNextWork, preflightBranchWithAgent, preparePrPacketWithAgent } from "../services/agent-orchestrator"; import { isAuthorizedGitHubSessionLogin } from "../auth/security"; import { commandAuthorizationAllowedRoles, commandAuthorizationNeedsMinerDetection } from "../settings/command-authorization"; +import { isAgentConfigured } from "../settings/autonomy"; +import { isGlobalAgentPause, resolveAgentActionMode } from "../settings/agent-execution"; +import { selectRegateCandidates } from "../settings/agent-sweep"; import { loadIssueQualityReportMap } from "../services/issue-quality"; import { generateWeeklyValueReport } from "../services/weekly-value-report"; import { REPO_OUTCOME_PATTERNS_SIGNAL, computeRepoOutcomePatterns } from "../services/repo-outcome-patterns"; @@ -278,6 +281,13 @@ export async function processJob(env: Env, message: JobMessage): Promise { case "generate-weekly-value-report": await generateWeeklyValueReport(env, { variant: message.variant ?? "operator", ...(message.days === undefined ? {} : { days: message.days }) }); return; + case "agent-regate-sweep": + if (!message.repoFullName && message.requestedBy !== "test") { + await fanOutAgentRegateSweepJobs(env, message.requestedBy); + return; + } + await sweepRepoRegate(env, message.repoFullName); + return; case "run-agent": await executeAgentRun(env, message.runId); return; @@ -331,6 +341,79 @@ async function fanOutRepoSignalSnapshotJobs(env: Env, requestedBy: "schedule" | }); } +// #777 scheduled re-gate sweep. The cron (index.ts) enqueues one fan-out job hourly; this enqueues a per-repo +// sweep job for every repo that opted the agent in (an acting autonomy level). Mirrors the signal-snapshot +// fan-out so each repo's sweep runs as its own bounded, retryable queue message. +async function fanOutAgentRegateSweepJobs(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { + const repositories = await listRepositories(env); + const configured: string[] = []; + for (const repo of repositories) { + const settings = await resolveRepositorySettings(env, repo.fullName); + if (isAgentConfigured(settings.autonomy)) configured.push(repo.fullName); + } + await Promise.all( + configured.map((repoFullName, index) => { + const message: JobMessage = { type: "agent-regate-sweep", requestedBy, repoFullName }; + const delaySeconds = Math.min(index * 10, 600); + return delaySeconds > 0 ? env.JOBS.send(message, { delaySeconds }) : env.JOBS.send(message); + }), + ); + await recordAuditEvent(env, { + eventType: "agent.sweep.fanout", + outcome: "queued", + metadata: { repoCount: configured.length, requestedBy }, + }); +} + +// Recompute the DETERMINISTIC gate verdict for a repo's stalest open PRs and record it as an audit event — +// ADVISORY ONLY: nothing is published to GitHub (no check, comment, or label) and no PR is mutated. This is +// the Phase-0 scheduling rail; the action layer (#778) is what will later turn a flagged verdict into a real +// action. Respects the #776 safety gate: a global or per-repo pause records a skip and recomputes nothing. +async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Promise { + if (!repoFullName) return; + const settings = await resolveRepositorySettings(env, repoFullName); + // Defensive: a repo can lose its acting autonomy between fan-out and processing. + if (!isAgentConfigured(settings.autonomy)) return; + const mode = resolveAgentActionMode({ + globalPaused: isGlobalAgentPause(env), + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + }); + if (mode === "paused") { + await recordAuditEvent(env, { + eventType: "agent.sweep.regate", + actor: "gittensory", + targetKey: repoFullName, + outcome: "denied", + detail: "agent actions paused — re-gate sweep skipped", + metadata: { repoFullName, mode }, + }); + return; + } + const [repo, openPullRequests] = await Promise.all([getRepository(env, repoFullName), listOpenPullRequests(env, repoFullName)]); + const candidates = selectRegateCandidates({ pulls: openPullRequests, now: nowIso() }); + // No stale PRs this tick — stay quiet rather than writing an empty heartbeat to the audit feed. + if (candidates.length === 0) return; + const requireLinkedIssue = settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off"; + const verdicts: Record = {}; + const flaggedPulls: number[] = []; + for (const pr of candidates) { + const others = openPullRequests.filter((other) => other.number !== pr.number); + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: others, requireLinkedIssue }); + const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null)); + verdicts[String(pr.number)] = gate.conclusion; + if (gate.conclusion === "failure" || gate.conclusion === "action_required") flaggedPulls.push(pr.number); + } + await recordAuditEvent(env, { + eventType: "agent.sweep.regate", + actor: "gittensory", + targetKey: repoFullName, + outcome: "completed", + detail: `scheduled re-gate recomputed ${candidates.length} stale open PR verdict(s); ${flaggedPulls.length} flagged`, + metadata: { repoFullName, mode, openCount: openPullRequests.length, examined: candidates.length, flagged: flaggedPulls.length, flaggedPulls, verdicts }, + }); +} + async function repairDataFidelity(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { const [repositories, segments, signalSnapshots] = await Promise.all([listRepositories(env), listRepoSyncSegments(env), listLatestSignalSnapshotsByTarget(env)]); const requiredSegments = new Set(["labels", "open_issues", "open_pull_requests"]); diff --git a/src/settings/agent-sweep.ts b/src/settings/agent-sweep.ts new file mode 100644 index 0000000000..df3f9ec6ae --- /dev/null +++ b/src/settings/agent-sweep.ts @@ -0,0 +1,45 @@ +import type { PullRequestRecord } from "../types"; + +// The scheduled re-gate sweep (#777) recomputes the gate verdict for OPEN PRs that no webhook is refreshing — +// the verdict can drift silently when the world changes under a static PR (the base advances, a sibling +// duplicate merges, the focus manifest or settings change). These pure helpers decide WHICH PRs a sweep +// recomputes so the processor stays a thin orchestration shell. + +// Rate-aware ceiling: never recompute more than this many PRs per repo per sweep, so a repo with a large +// open queue cannot blow the queue-message budget. The stalest are picked first. +export const SWEEP_MAX_PRS = 25; + +// Skip-if-fresh window: a PR touched within this span was almost certainly just gated by its webhook, so the +// sweep leaves it alone and spends its budget on genuinely stale PRs. One hour mirrors the sweep cadence. +export const SWEEP_FRESHNESS_MS = 60 * 60 * 1000; + +/** + * Select the open PRs a single repo sweep should recompute: drop drafts and anything updated within + * `freshnessWindowMs` of `now` (recently active → already gated), then take the `max` STALEST by `updatedAt` + * ascending (a missing `updatedAt` sorts oldest — it has gone longest without a recorded refresh). Pure and + * deterministic: same inputs → same ordered batch, which is what makes the sweep idempotent. + */ +export function selectRegateCandidates(input: { + pulls: PullRequestRecord[]; + now: string; + freshnessWindowMs?: number; + max?: number; +}): PullRequestRecord[] { + const freshnessWindowMs = input.freshnessWindowMs ?? SWEEP_FRESHNESS_MS; + const max = input.max ?? SWEEP_MAX_PRS; + const nowMs = Date.parse(input.now); + const freshCutoff = Number.isFinite(nowMs) ? nowMs - freshnessWindowMs : Number.NaN; + const staleness = (pr: PullRequestRecord): number => { + const updated = pr.updatedAt ? Date.parse(pr.updatedAt) : Number.NaN; + // A missing/unparseable timestamp is treated as maximally stale (epoch) so it is never starved. + return Number.isFinite(updated) ? updated : 0; + }; + return input.pulls + .filter((pr) => pr.state === "open" && !pr.isDraft) + .filter((pr) => { + if (!Number.isFinite(freshCutoff)) return true; + return staleness(pr) <= freshCutoff; + }) + .sort((a, b) => staleness(a) - staleness(b) || a.number - b.number) + .slice(0, Math.max(0, max)); +} diff --git a/src/settings/autonomy.ts b/src/settings/autonomy.ts index dcb3b6fb09..fab08bbb9a 100644 --- a/src/settings/autonomy.ts +++ b/src/settings/autonomy.ts @@ -26,6 +26,15 @@ export function isActingAutonomyLevel(level: AutonomyLevel): boolean { return level === "auto" || level === "auto_with_approval"; } +/** + * True when a repo has opted into the agent layer at all — i.e. at least one action class has an acting + * autonomy level. The deny-by-default floor (every class `observe`) is NOT configured. The scheduled + * re-gate sweep (#777) uses this to skip repos that never asked the agent to act. Pure. + */ +export function isAgentConfigured(autonomy: AutonomyPolicy | null | undefined): boolean { + return AGENT_ACTION_CLASSES.some((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); +} + /** True when the action must pass a human approval gate (#779) before it executes. */ export function autonomyRequiresApproval(level: AutonomyLevel): boolean { return level === "auto_with_approval"; diff --git a/src/types.ts b/src/types.ts index 293b63eac5..1156143490 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,6 +110,13 @@ export type JobMessage = variant?: WeeklyValueReportVariant; days?: number; } + | { + // Scheduled re-gate sweep (#777). No `repoFullName` = fan-out: enqueue one per agent-configured repo. + // With `repoFullName` = recompute the gate verdict for that repo's stale open PRs (advisory/audit only). + type: "agent-regate-sweep"; + requestedBy: "schedule" | "api" | "test"; + repoFullName?: string; + } | { type: "run-agent"; requestedBy: "api" | "mcp" | "github_comment" | "test"; diff --git a/test/unit/agent-sweep.test.ts b/test/unit/agent-sweep.test.ts new file mode 100644 index 0000000000..874a7694f2 --- /dev/null +++ b/test/unit/agent-sweep.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { SWEEP_FRESHNESS_MS, SWEEP_MAX_PRS, selectRegateCandidates } from "../../src/settings/agent-sweep"; +import type { PullRequestRecord } from "../../src/types"; + +const NOW = "2026-06-17T12:00:00.000Z"; +const nowMs = Date.parse(NOW); +const minutesAgo = (m: number): string => new Date(nowMs - m * 60 * 1000).toISOString(); + +function pr(overrides: Partial & { number: number }): PullRequestRecord { + return { + repoFullName: "owner/repo", + title: `PR ${overrides.number}`, + state: "open", + labels: [], + linkedIssues: [], + ...overrides, + }; +} + +describe("selectRegateCandidates (#777 re-gate sweep selection)", () => { + it("drops PRs updated within the freshness window (recently gated by their webhook)", () => { + const pulls = [pr({ number: 1, updatedAt: minutesAgo(5) }), pr({ number: 2, updatedAt: minutesAgo(120) })]; + const picked = selectRegateCandidates({ pulls, now: NOW }); + expect(picked.map((p) => p.number)).toEqual([2]); + }); + + it("orders the stalest first and bounds to max (rate-aware)", () => { + const pulls = [ + pr({ number: 1, updatedAt: minutesAgo(120) }), + pr({ number: 2, updatedAt: minutesAgo(600) }), + pr({ number: 3, updatedAt: minutesAgo(300) }), + ]; + const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 }); + expect(picked.map((p) => p.number)).toEqual([2, 3]); // stalest (600m), then 300m; 120m dropped by cap + }); + + it("treats a missing updatedAt as maximally stale and never starves it", () => { + const pulls = [pr({ number: 1, updatedAt: minutesAgo(120) }), pr({ number: 2 })]; + const picked = selectRegateCandidates({ pulls, now: NOW }); + expect(picked.map((p) => p.number)).toEqual([2, 1]); // no-timestamp PR sorts oldest + }); + + it("excludes drafts and non-open PRs", () => { + const pulls = [ + pr({ number: 1, updatedAt: minutesAgo(120), isDraft: true }), + pr({ number: 2, updatedAt: minutesAgo(120), state: "closed" }), + pr({ number: 3, updatedAt: minutesAgo(120) }), + ]; + const picked = selectRegateCandidates({ pulls, now: NOW }); + expect(picked.map((p) => p.number)).toEqual([3]); + }); + + it("is deterministic: equal staleness breaks ties by PR number", () => { + const ts = minutesAgo(200); + const pulls = [pr({ number: 9, updatedAt: ts }), pr({ number: 4, updatedAt: ts }), pr({ number: 7, updatedAt: ts })]; + const picked = selectRegateCandidates({ pulls, now: NOW }); + expect(picked.map((p) => p.number)).toEqual([4, 7, 9]); + }); + + it("keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)", () => { + const pulls = [pr({ number: 1, updatedAt: minutesAgo(5) }), pr({ number: 2, updatedAt: minutesAgo(600) }), pr({ number: 3, isDraft: true })]; + const picked = selectRegateCandidates({ pulls, now: "not-a-date", freshnessWindowMs: 30 * 60 * 1000 }); + expect(picked.map((p) => p.number)).toEqual([2, 1]); // drafts still excluded; both non-draft kept, stalest first + }); + + it("defaults: freshness window is one hour and the cap is 25", () => { + expect(SWEEP_FRESHNESS_MS).toBe(60 * 60 * 1000); + expect(SWEEP_MAX_PRS).toBe(25); + const pulls = Array.from({ length: 40 }, (_, i) => pr({ number: i + 1, updatedAt: minutesAgo(120 + i) })); + expect(selectRegateCandidates({ pulls, now: NOW })).toHaveLength(25); + }); +}); diff --git a/test/unit/autonomy.test.ts b/test/unit/autonomy.test.ts index c46d1bb8d0..6bc2cf8f1e 100644 --- a/test/unit/autonomy.test.ts +++ b/test/unit/autonomy.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, + isAgentConfigured, normalizeAutoMaintainPolicy, normalizeAutonomyPolicy, resolveAutonomy, @@ -107,3 +108,18 @@ describe("normalizeAutoMaintainPolicy (#774)", () => { expect(AUTO_MERGE_METHODS).toEqual(["merge", "squash", "rebase"]); }); }); + +describe("isAgentConfigured (#777 opt-in detection)", () => { + it("is true when any action class has an acting level", () => { + expect(isAgentConfigured({ merge: "auto" })).toBe(true); + expect(isAgentConfigured({ label: "auto_with_approval" })).toBe(true); + expect(isAgentConfigured({ review: "suggest", close: "auto" })).toBe(true); + }); + + it("is false for the deny-by-default floor (all observe / non-acting / empty / null)", () => { + expect(isAgentConfigured({ merge: "observe", review: "suggest", approve: "propose" })).toBe(false); + expect(isAgentConfigured({})).toBe(false); + expect(isAgentConfigured(null)).toBe(false); + expect(isAgentConfigured(undefined)).toBe(false); + }); +}); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 35231b9fc5..177edf9b05 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -108,6 +108,7 @@ describe("worker entrypoint", () => { { type: "refresh-scoring-model", requestedBy: "schedule" }, { type: "refresh-upstream-drift", requestedBy: "schedule" }, { type: "rollup-product-usage", requestedBy: "schedule", days: 7 }, + { type: "agent-regate-sweep", requestedBy: "schedule" }, ]); }); @@ -133,6 +134,7 @@ describe("worker entrypoint", () => { { type: "refresh-scoring-model", requestedBy: "schedule" }, { type: "refresh-upstream-drift", requestedBy: "schedule" }, { type: "rollup-product-usage", requestedBy: "schedule", days: 7 }, + { type: "agent-regate-sweep", requestedBy: "schedule" }, { type: "generate-signal-snapshots", requestedBy: "schedule" }, { type: "build-burden-forecasts", requestedBy: "schedule" }, { type: "build-contributor-evidence", requestedBy: "schedule" }, diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index d35edc2a7f..cd55faff32 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -509,6 +509,112 @@ describe("queue processors", () => { ]); }); + it("agent re-gate sweep fans out only to repos that opted the agent in (#777)", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await upsertRepositoryFromGitHub(env, { name: "agent-a", full_name: "owner/agent-a", private: false, owner: { login: "owner" } }); + await upsertRepositoryFromGitHub(env, { name: "agent-b", full_name: "owner/agent-b", private: false, owner: { login: "owner" } }); + await upsertRepositoryFromGitHub(env, { name: "plain-repo", full_name: "owner/plain-repo", private: false, owner: { login: "owner" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-a", autonomy: { label: "auto" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-b", autonomy: { merge: "auto_with_approval" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/plain-repo", autonomy: { review: "observe" } }); // non-acting → not configured + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "schedule" }); + + expect(sent).toHaveLength(2); + expect(sent.every((message) => message.type === "agent-regate-sweep")).toBe(true); + expect(sent.map((message) => (message.type === "agent-regate-sweep" ? message.repoFullName : null)).sort()).toEqual(["owner/agent-a", "owner/agent-b"]); + const fanout = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("agent.sweep.fanout").first<{ + outcome: string; + metadata_json: string; + }>(); + expect(fanout?.outcome).toBe("queued"); + expect(JSON.parse(fanout?.metadata_json ?? "{}")).toMatchObject({ repoCount: 2, requestedBy: "schedule" }); + }); + + it("agent re-gate sweep recomputes stale open PR verdicts as an advisory audit, never publishing (#777)", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, linkedIssueGateMode: "block" }); + // #7 has no linked issue → blocked under linkedIssueGateMode:block; #8 links one → passes. Both are stale. + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Unlinked PR", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, labels: [], body: "no linked issue here" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 8, title: "Linked PR", state: "open", user: { login: "contributor" }, head: { sha: "a8" }, labels: [], body: "Closes #1" }); + // Advance past the one-hour freshness window so the just-seeded PRs read as stale. + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const audit = await env.DB.prepare("select outcome, detail, metadata_json from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ + outcome: string; + detail: string; + metadata_json: string; + }>(); + expect(audit?.outcome).toBe("completed"); + const meta = JSON.parse(audit?.metadata_json ?? "{}"); + expect(meta).toMatchObject({ repoFullName: "owner/agent-repo", mode: "live", examined: 2, flagged: 1 }); + expect(meta.flaggedPulls).toEqual([7]); + expect(meta.verdicts).toMatchObject({ "7": "failure", "8": "success" }); + // Advisory only: the sweep enqueues no jobs and posts no check/comment/label. + expect(sent).toEqual([]); + }); + + it("agent re-gate sweep respects the #776 kill-switch: a paused repo records a skip and recomputes nothing (#777)", async () => { + const env = createTestEnv({}); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, agentPaused: true }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Stale PR", state: "open", user: { login: "contributor" }, head: { sha: "abc" }, labels: [], body: "x" }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const audit = await env.DB.prepare("select outcome, detail, metadata_json from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ + outcome: string; + detail: string; + metadata_json: string; + }>(); + expect(audit?.outcome).toBe("denied"); + expect(audit?.detail).toMatch(/paused/i); + expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ mode: "paused" }); + }); + + it("agent re-gate sweep no-ops safely on a missing repo arg or an un-configured repo (#777)", async () => { + const env = createTestEnv({}); + // (a) a test-mode per-repo job with no repoFullName → defensive early return + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test" }); + // (b) a repo that never opted the agent in → defensive return after settings resolve + await upsertRepositoryFromGitHub(env, { name: "plain-repo", full_name: "owner/plain-repo", private: false, owner: { login: "owner" } }); + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/plain-repo" }); + + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it("agent re-gate sweep stays quiet when no open PR is stale enough to re-gate (#777)", async () => { + const env = createTestEnv({}); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" } }); + // Seeded "now" → within the freshness window → not a candidate; no clock advance. + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Fresh PR", state: "open", user: { login: "c" }, head: { sha: "a7" }, labels: [], body: "x" }); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + it("routes repo-scoped backfill jobs into resumable segment and detail processors", async () => { const sent: import("../../src/types").JobMessage[] = []; const env = createTestEnv({