Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
83 changes: 83 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -278,6 +281,13 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
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;
Expand Down Expand Up @@ -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<void> {
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<void> {
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<string, string> = {};
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<void> {
const [repositories, segments, signalSnapshots] = await Promise.all([listRepositories(env), listRepoSyncSegments(env), listLatestSignalSnapshotsByTarget(env)]);
const requiredSegments = new Set(["labels", "open_issues", "open_pull_requests"]);
Expand Down
45 changes: 45 additions & 0 deletions src/settings/agent-sweep.ts
Original file line number Diff line number Diff line change
@@ -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));
}
9 changes: 9 additions & 0 deletions src/settings/autonomy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
72 changes: 72 additions & 0 deletions test/unit/agent-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -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<PullRequestRecord> & { 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);
});
});
16 changes: 16 additions & 0 deletions test/unit/autonomy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DEFAULT_AUTO_MAINTAIN_POLICY,
autonomyRequiresApproval,
isActingAutonomyLevel,
isAgentConfigured,
normalizeAutoMaintainPolicy,
normalizeAutonomyPolicy,
resolveAutonomy,
Expand Down Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]);
});

Expand All @@ -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" },
Expand Down
Loading
Loading