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
5 changes: 5 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,11 @@ declare global {
* src/review/active-review-reconciliation.ts). Default OFF — unset/false means the cron tick enqueues NO
* reconciliation job, so the worker is byte-identical to today. */
LOOPOVER_ACTIVE_REVIEW_RECONCILIATION?: string;
/** APR repo-transfer acceptance/expiry detection (#7741): when truthy, an hourly cron enqueues a
* `poll-apr-repo-transfers` job that, for each pending transfer, probes GitHub and marks it accepted /
* accepted-and-departed / expired (>7 days), reconciling the per-repo AMS-dispatch pause. Default OFF —
* unset/false means the cron tick enqueues NO poll job, so the worker is byte-identical to today. */
LOOPOVER_APR_TRANSFER_POLL?: string;
/** Convergence (RAG retrieval): when truthy, the AI reviewer prompt gains a RELEVANT EXISTING CODE / DOCS
* section — at review time the codebase vector index is queried for code/docs semantically related to the
* PR's changed files (callers, related modules, existing conventions) and appended as additive reference
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isOpsEnabled, resolveOpsManifestOverride } from "./review/ops-wire";
import { isRecapEnabled, resolveMaintainerRecapManifestOverride, shouldFireMaintainerRecap } from "./review/maintainer-recap-wire";
import { isSweepWatchdogEnabled, resolveSweepWatchdogManifestOverride } from "./review/sweep-watchdog";
import { isLoopEscalationSweepEnabled } from "./review/loop-escalation-wire";
import { isAprRepoTransferPollEnabled } from "./orb/apr-repo-transfer";
import { isPrReconciliationEnabled, resolvePrReconciliationManifestOverride } from "./review/pr-reconciliation";
import { isActiveReviewReconciliationEnabled, resolveActiveReviewReconciliationManifestOverride } from "./review/active-review-reconciliation";
import { isRagEnabled } from "./review/rag-wire";
Expand Down Expand Up @@ -273,6 +274,11 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
// Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created, so the cron tick does
// ZERO new tuning work and the enqueued set is byte-identical to today.
if (selfHostedReviews && isSelfTuneEnabled(env)) jobs.push({ type: "selftune", requestedBy: "schedule" });
// APR repo-transfer acceptance/expiry detection (#7741, flag LOOPOVER_APR_TRANSFER_POLL). Hourly poll that
// resolves each pending APR transfer (accepted / accepted-and-departed / expired at 7 days) and reconciles
// the per-repo AMS pause. Enqueued ONLY when the flag is ON — flag-OFF (default) this job is never created,
// so the cron tick does ZERO new work and the enqueued set is byte-identical to today.
if (isAprRepoTransferPollEnabled(env)) jobs.push({ type: "poll-apr-repo-transfers", requestedBy: "schedule" });
}
if (isHourly && scheduledAt.getUTCDay() === 1 && hour === 12) {
jobs.push({ type: "generate-weekly-value-report", requestedBy: "schedule", variant: "operator", days: 7 });
Expand Down
176 changes: 175 additions & 1 deletion src/orb/apr-repo-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// boolean over the wire; {@link loadAprIdeaCompletion} is the sole source, and it fail-closes until #7664
// persists a completion record.

import { upsertRepositorySettings } from "../db/repositories";
import { createInstallationToken } from "../github/app";
import { githubHeaders, timeoutFetch } from "../github/client";
import { loadAprIdeaCompletion, type AprIdeaCompletionLookup } from "./apr-idea-completion";
Expand Down Expand Up @@ -115,6 +116,8 @@ export async function requestAprRepoTransfer(
newOwner: string,
) => Promise<AprRepoTransferResult>;
loadCompletion?: AprIdeaCompletionLookup;
/** #7741 deliverable 2 seam: how to freeze AMS dispatch once a transfer is pending. Injectable for tests. */
pauseDispatch?: (env: Env, repoFullName: string) => Promise<void>;
} = {},
): Promise<RequestAprRepoTransferResult> {
const loadCompletion = options.loadCompletion ?? loadAprIdeaCompletion;
Expand All @@ -124,6 +127,177 @@ export async function requestAprRepoTransfer(

const initiate = options.initiate ?? initiateAprRepoTransfer;
const transfer = await initiate(env, input.installationId, input.repoFullName, input.newOwner);
if (transfer.initiated) return { status: "initiated", transfer };
if (transfer.initiated) {
// #7741 deliverable 2: a pending transfer is acceptance-gated and asynchronous, so freeze AMS dispatch for
// the source repo the instant GitHub accepts the request — reusing the EXISTING per-repo `agentPaused`
// kill-switch, not a new mechanism. The scheduled poll ({@link pollPendingAprRepoTransfers}) resumes it once
// the transfer is accepted-and-still-installed, or expires/declines.
const pauseDispatch = options.pauseDispatch ?? ((e, r) => setAprRepoDispatchPaused(e, r, true));
await pauseDispatch(env, input.repoFullName);
return { status: "initiated", transfer };
}
return { status: "failed", transfer };
}

// ---------------------------------------------------------------------------------------------------------------
// #7741: detect whether a PENDING transfer was accepted, declined, or expired, and reconcile the per-repo pause.
//
// GitHub repo transfers are asynchronous + acceptance-gated (see {@link AprRepoTransferResult}), so a
// scheduled poll — NOT a webhook (design ratified in #7741) — reconciles each pending transfer. All IO (the
// GitHub probe, the clock, the pending-transfer store, the pause toggle) is INJECTED so the detection/expiry
// logic is unit-testable without the live cron; the cron itself only wires these real dependencies together.
// ---------------------------------------------------------------------------------------------------------------

/**
* A pending APR repo transfer the scheduled poll must resolve (#7741). Persisting these rows is a separate
* concern (#7664 completion/record store); this module only needs what it takes to probe GitHub and time out.
*/
export type PendingAprRepoTransfer = {
/** The loopover-org path (`owner/name`) the transfer was initiated FROM. */
repoFullName: string;
/** The GitHub account the repo is moving TO. */
newOwner: string;
/** Installation whose App token can read the repo — the same token source as initiation. */
installationId: number;
/** Epoch-ms when {@link initiateAprRepoTransfer} accepted the pending transfer. */
initiatedAt: number;
};

/** What a single GitHub repo-probe reveals about a pending transfer (#7741). */
export type AprRepoTransferProbe =
| { state: "resolved_under_target" } // the repo now resolves under `newOwner` — accepted.
| { state: "access_departed" } // the App's access 404s, consistent with ownership having moved — accepted-and-departed.
| { state: "pending" }; // still under the original owner (or a transient error) — keep waiting.

/** Outcomes of a pending transfer (#7741). Everything except `pending` is terminal. */
export type AprRepoTransferOutcome = "accepted" | "accepted_departed" | "expired" | "pending";
export type TerminalAprRepoTransferOutcome = Exclude<AprRepoTransferOutcome, "pending">;

/** A pending transfer that neither resolves nor departs within this window (from initiation) is expired (#7741). */
export const APR_REPO_TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;

/** Default-OFF flag (#7741): flag-OFF, the cron enqueues no poll job, so the worker is byte-identical to today. */
export function isAprRepoTransferPollEnabled(env: { LOOPOVER_APR_TRANSFER_POLL?: string | undefined }): boolean {
return /^(1|true|yes|on)$/i.test((env.LOOPOVER_APR_TRANSFER_POLL ?? "").trim());
}

/**
* Decide a pending transfer's outcome from a repo probe + elapsed time (#7741). Pure and deterministic.
* A resolved/departed probe is terminal immediately; otherwise the transfer stays pending until it has been
* outstanding for `expiryMs` (default {@link APR_REPO_TRANSFER_EXPIRY_MS}), at which point it is expired.
*/
export function classifyAprRepoTransferOutcome(input: {
probe: AprRepoTransferProbe;
initiatedAt: number;
now: number;
expiryMs?: number;
}): AprRepoTransferOutcome {
if (input.probe.state === "resolved_under_target") return "accepted";
if (input.probe.state === "access_departed") return "accepted_departed";
const expiryMs = input.expiryMs ?? APR_REPO_TRANSFER_EXPIRY_MS;
if (input.now - input.initiatedAt >= expiryMs) return "expired";
return "pending";
}

/**
* Probe GitHub for the current state of a pending transfer (#7741): read the repo at its ORIGINAL path with the
* App installation token (same token source as initiation). GitHub redirects a completed transfer to its new
* location, so a 2xx whose owner is now `newOwner` means accepted; a 404 means the App lost access because
* ownership moved (accepted-and-departed); anything else (still under the original owner, or a transient error)
* is treated as still pending so the next poll retries. Never throws.
*/
export async function probeAprRepoTransfer(
env: Env,
transfer: Pick<PendingAprRepoTransfer, "repoFullName" | "newOwner" | "installationId">,
): Promise<AprRepoTransferProbe> {
const token = await createInstallationToken(env, transfer.installationId);
const response = await timeoutFetch(`https://github.com/@api/repos/${transfer.repoFullName}`, {
headers: githubHeaders({ token }),
});
if (response.status === 404) return { state: "access_departed" };
if (!response.ok) return { state: "pending" };
const body = (await response.json().catch(() => null)) as { owner?: { login?: string } } | null;
const owner = body?.owner?.login;
if (owner && owner.toLowerCase() === transfer.newOwner.toLowerCase()) return { state: "resolved_under_target" };
return { state: "pending" };
}

/**
* Pause or resume AMS dispatch for a repo by toggling the EXISTING per-repo `agentPaused` kill-switch (#7741
* deliverable 2) — no new pause mechanism. Freezes dispatch while a transfer is pending; releases it once the
* transfer resolves or expires.
*/
export async function setAprRepoDispatchPaused(env: Env, repoFullName: string, paused: boolean): Promise<void> {
await upsertRepositorySettings(env, { repoFullName, agentPaused: paused });
}

/**
* Load the transfers still awaiting acceptance (#7741). Fail-empty until the pending-transfer record store
* (#7664) lands: today there is nothing to persist a pending row to, so — exactly like
* {@link loadAprIdeaCompletion} — this returns none and the poll no-ops. Swap the body (keep the signature)
* once #7664 persists rows and every caller picks it up.
*/
export async function loadPendingAprRepoTransfers(_env: Env): Promise<PendingAprRepoTransfer[]> {
return [];
}

/**
* Record a resolved transfer's terminal outcome (#7741). No-op until the pending-transfer record store (#7664)
* lands — mirrors {@link loadPendingAprRepoTransfers}. Kept as an injectable seam so the poll's terminal branch
* is exercised and swapping in real persistence needs no call-site change.
*/
export async function recordAprRepoTransferOutcome(
_env: Env,
_transfer: PendingAprRepoTransfer,
_outcome: TerminalAprRepoTransferOutcome,
): Promise<void> {
// Intentionally empty until #7664 persists a pending-transfer record to update.
}

/** Injected dependencies for {@link pollPendingAprRepoTransfers}. Every seam is provided so it is cron-free testable. */
export type AprRepoTransferPollDeps = {
listPending: (env: Env) => Promise<PendingAprRepoTransfer[]>;
probe: (env: Env, transfer: PendingAprRepoTransfer) => Promise<AprRepoTransferProbe>;
now: () => number;
markResolved: (env: Env, transfer: PendingAprRepoTransfer, outcome: TerminalAprRepoTransferOutcome) => Promise<void>;
setDispatchPaused: (env: Env, repoFullName: string, paused: boolean) => Promise<void>;
expiryMs?: number;
};

/** Per-transfer result of one poll pass (#7741). */
export type AprRepoTransferPollResult = { repoFullName: string; outcome: AprRepoTransferOutcome };

/**
* Resolve every pending APR repo transfer in one poll pass (#7741 deliverables 1+2). For each pending transfer:
* probe GitHub, classify the outcome, and reconcile the per-repo pause —
* - `pending`: keep AMS dispatch frozen (idempotent re-assert) and leave the record pending;
* - `accepted` (App still installed) or `expired`/declined (the repo never left): record it and RESUME dispatch;
* - `accepted_departed` (App lost access — ownership moved away): record it but leave dispatch alone — there is
* nothing left to resume.
* All IO is injected, so the detection/expiry/pause logic is unit-testable without the live cron.
*/
export async function pollPendingAprRepoTransfers(
env: Env,
deps: AprRepoTransferPollDeps,
): Promise<AprRepoTransferPollResult[]> {
const pending = await deps.listPending(env);
const now = deps.now();
const results: AprRepoTransferPollResult[] = [];
for (const transfer of pending) {
const probe = await deps.probe(env, transfer);
const outcome = classifyAprRepoTransferOutcome({
probe,
initiatedAt: transfer.initiatedAt,
now,
...(deps.expiryMs !== undefined ? { expiryMs: deps.expiryMs } : {}),
});
if (outcome === "pending") {
await deps.setDispatchPaused(env, transfer.repoFullName, true);
} else {
await deps.markResolved(env, transfer, outcome);
if (outcome !== "accepted_departed") await deps.setDispatchPaused(env, transfer.repoFullName, false);
}
results.push({ repoFullName: transfer.repoFullName, outcome });
}
return results;
}
21 changes: 21 additions & 0 deletions src/queue/job-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ import { runSelfTuneBreaker } from "../review/outcomes-wire";
import { isRagEnabled } from "../review/rag-wire";
import { processSubmitDraft } from "../services/draft";
import { retryFailedRelays } from "../orb/relay";
import {
loadPendingAprRepoTransfers,
pollPendingAprRepoTransfers,
probeAprRepoTransfer,
recordAprRepoTransferOutcome,
setAprRepoDispatchPaused,
} from "../orb/apr-repo-transfer";
import { syncBrokeredInstalledRepos } from "../orb/installed-repos-sync";
import { incr } from "../selfhost/metrics";
import { generateSignalSnapshots } from "./signal-snapshot";
Expand Down Expand Up @@ -388,6 +395,20 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
// an empty table). Never throws.
await retryFailedRelays(env);
return;
/* v8 ignore start -- live-loop wiring: binds the injectable, unit-tested pollPendingAprRepoTransfers (#7741)
to its real dependencies. The detection/expiry/pause logic is covered directly in
test/unit/orb-apr-repo-transfer.test.ts; this arm is a no-op today (loadPendingAprRepoTransfers fail-empties
until #7664 persists rows) and is enqueued only when LOOPOVER_APR_TRANSFER_POLL is set. */
case "poll-apr-repo-transfers":
await pollPendingAprRepoTransfers(env, {
listPending: loadPendingAprRepoTransfers,
probe: probeAprRepoTransfer,
now: Date.now,
markResolved: recordAprRepoTransferOutcome,
setDispatchPaused: setAprRepoDispatchPaused,
});
return;
/* v8 ignore stop */
default:
// An unrecognized job type (a stale queued message from a renamed/removed type, a producer/consumer skew
// during a rolling deploy, or a corrupted payload) would otherwise fall through and be acked with zero
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,13 @@ export type JobMessage =
type: "retry-orb-relay";
requestedBy: "schedule" | "test";
}
| {
// APR repo-transfer acceptance/expiry detection (#7741): resolve every pending APR transfer — probe GitHub,
// mark accepted / accepted-and-departed / expired (>7 days), reconcile the per-repo AMS pause. Enqueued by
// the cron hourly ONLY when LOOPOVER_APR_TRANSFER_POLL is set; flag-OFF (default) it is never created.
type: "poll-apr-repo-transfers";
requestedBy: "schedule" | "test";
}
| {
// Self-host backlog-convergence sweep (#selfhost-backlog-convergence): finds open PRs whose public review
// surface was never published for their current head (a blind spot the periodic re-gate sweep's dispatch-
Expand Down
18 changes: 18 additions & 0 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,24 @@ describe("worker entrypoint", () => {
expect(sent).toEqual([{ type: "agent-regate-sweep", requestedBy: "schedule" }]);
});

it("enqueues the APR repo-transfer poll on an hourly tick only when LOOPOVER_APR_TRANSFER_POLL is set (#7741)", async () => {
const captured: Array<import("../../src/types").JobMessage> = [];
const env = createTestEnv({
LOOPOVER_APR_TRANSFER_POLL: "1",
JOBS: {
async send(message: import("../../src/types").JobMessage) {
captured.push(message);
},
} as unknown as Queue,
});
const waitUntil: Promise<unknown>[] = [];

await worker.scheduled(controllerFor("2026-05-25T05:00:00.000Z"), env, executionContext(waitUntil));
await Promise.all(waitUntil);

expect(captured).toContainEqual({ type: "poll-apr-repo-transfers", requestedBy: "schedule" });
});

it("keeps enqueueing scheduled sweeps while prior per-PR regate jobs are queued (#2119)", async () => {
// Per-PR "agent-regate-pr" backlog is normal, expected, ongoing work (staggered/rate-deferred re-reviews) —
// it must NOT block the next scheduled fan-out trigger, or the sweep starves under any sustained load.
Expand Down
Loading