Skip to content

Commit 70b60e7

Browse files
authored
fix(selfhost): rescue orphaned dispositions, break the freeze-deadlock, and release AI-review locks before a hard kill can (#9174)
#8997 — a deploy restart can leave a PR wearing a decisive panel with no matching disposition. SIGTERM kills whatever pass is in flight at an arbitrary point, and the worst possible cut is between the public-surface publish (panel, gate check-run, CI aggregate — all committed) and maybeRunAgentMaintenance ever claiming its per-PR actuation lock for that head. Confirmed live on #8965: the panel published at 14:55:24Z from the dying container, and the matching close only landed at 15:07:53Z — ~12 minutes later, purely because an unrelated later sweep tick happened to re-run the whole pass from scratch. The existing outage-repair check (lastPublishedSurfaceSha !== headSha) cannot see this: the surface IS current, so it already looks healthy. maybeRunAgentMaintenance now records a marker (agent.maintenance.disposition_ considered) the moment it claims the actuation lock — the moment a real disposition attempt begins, independent of what it decides. A new standalone scan, reconcileSurfaceWithoutDisposition, rides the same sweep tick as the existing pr-outcome/pending-closure repair scans (#9026/#9031) and re-enqueues a regate for any open PR whose current-head surface carries no such marker. Deliberately NOT folded into the existing surfaceRepairPriorityPullNumbers: that function's return value drives the regular sweep's staleness-ordered fan-out and is asserted against by a large, already-green test surface (dispatch shape, ordering, backlog-restriction semantics) — an early attempt at injecting this check there flagged every "surface current, nothing else recorded" fixture in that file, which is a real, deliberate policy widening this fix makes, but one that belongs in its own independently-testable scan rather than retrofitted onto a heavily-relied-upon function blind. #8998 — an orphaned ai-review-lock (30-min TTL) starves every subsequent re-review, including an explicit maintainer re-run, with the "already in progress" placeholder for real time nobody is reviewing anything. Two of the three layers this needed were already shipped earlier this session: the boot-time flush (#9021, ORPHANED_LOCK_KEY_PATTERNS already includes ai-review-lock:*) and the explicit force-lock-steal for a maintainer's forced re-run (#9008, `steal: webhook.forceAiReview === true` already threaded into claimAiReviewLock). What was still missing: queue.stop() (#9007) lets an in-flight job finish naturally, releasing its own lock via its own finally block — but only if the orchestrator's SIGKILL grace period is long enough for that drain to complete, and a common 10-30s grace period is shorter than an AI-review LLM call legitimately runs. A new held-lock-registry tracks every real ai-review-lock claim this process currently holds; the shutdown handler now releases all of them FIRST, before anything else in the shutdown sequence, so the lock is gone the instant SIGTERM/SIGINT arrives regardless of whether the rest of shutdown gets time to finish. Complementary to the boot-time flush, not a replacement for it: a true `kill -9` delivers no signal at all, so that backstop still matters. #8999 — a lock-contended hold applied the sticky manual-review label, and the label then froze every LATER pass from running a FRESH AI review at all (only replay) — meaning the one thing that could produce the real verdict needed to auto-clear the label (already implemented, #9009) could never run, because the freeze it depends on being lifted is gated by the very label it's trying to clear. #9009 only handled the case where a fresh review DID run and showed contention had resolved; it could not reach that state on a repo requiring blocking AI review, because the freeze blocks the fresh attempt outright. Fixed by exempting the freeze specifically when the label's own provenance — per the same transient marker #9009 already reads/writes — is the lock-contention hold and nothing else. This does not reopen the gaming surface the freeze exists to close (a contributor iterating pushes to buy a fresh verdict): a lock-contention hold is an infra artifact of two ORB passes racing, not a contributor action, and if some OTHER reason also justifies the hold, that reason re-applies the label on this same pass's own disposition regardless of the exemption. #9013's largest remaining piece (a single per-PR mutex spanning the public-surface publish AND the disposition plan/execute, currently two separately-claimed critical sections) is NOT included here — an initial attempt showed it needs a moderate refactor of both giant call sites (the sweep/CI- completion path and the webhook path) with real risk of subtly changing span/catch/decisionOutcome semantics the existing test suite exercises extensively, and that deserves its own dedicated, carefully-tested pass rather than a rushed addition alongside three already-substantial fixes. Targeted tests only (no full local gate run this pass, per instruction): typecheck clean, and every touched/new test file passes locally — 39 new tests across surface-disposition-reconciler.test.ts, held-lock-registry.test.ts, and the extended job-dispatch.test.ts fan-out coverage — plus a broader targeted sweep of 967 tests across the queue/lifecycle/transient-lock/precision-breaker suites most likely to interact with the actuation-lock, freeze, and sweep- priority code paths this change touches. CI runs the full gate on push. Closes #8997 Closes #8998 Closes #8999
1 parent c91686a commit 70b60e7

9 files changed

Lines changed: 548 additions & 17 deletions

src/queue/ai-review-orchestration.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "./transient-locks";
2222
import { buildPullRequestAdvisory } from "../rules/advisory";
2323
import { recordAuditEvent, getDecryptedRepositoryAiKey, getRepository, listCheckSummaries, listPullRequestFiles } from "../db/repositories";
24+
import { registerHeldLock, unregisterHeldLock } from "./held-lock-registry";
2425
import { recordRoutingShadow } from "../services/reviewer-routing";
2526
import { createInstallationToken } from "../github/app";
2627
import type { AgentActionMode } from "../settings/agent-execution";
@@ -120,12 +121,15 @@ export async function claimAiReviewLock(
120121
mode: string,
121122
options?: { steal?: boolean },
122123
): Promise<TransientLockClaim> {
123-
return claimTransientLock(
124-
env,
125-
aiReviewLockKey(repoFullName, prNumber, headSha, mode),
126-
AI_REVIEW_LOCK_TTL_SECONDS,
127-
options,
128-
);
124+
const key = aiReviewLockKey(repoFullName, prNumber, headSha, mode);
125+
const claim = await claimTransientLock(env, key, AI_REVIEW_LOCK_TTL_SECONDS, options);
126+
// #8998: register a REAL claim (a non-null ownerToken means this call, not a fail-open passthrough or a
127+
// no-op adapter, actually owns the key) so a shutdown signal can release it immediately instead of only via
128+
// graceful drain or the full 1800s TTL. A fail-open claim (ownerToken null) has nothing to release.
129+
if (claim.acquired && claim.ownerToken !== null) {
130+
registerHeldLock(key, () => releaseTransientLockIfOwner(env, key, claim.ownerToken));
131+
}
132+
return claim;
129133
}
130134

131135
/** Best-effort release, called from a finally block so the lock frees promptly instead of waiting out the TTL. */
@@ -137,7 +141,12 @@ export async function releaseAiReviewLock(
137141
mode: string,
138142
ownerToken: string | null,
139143
): Promise<void> {
140-
await releaseTransientLockIfOwner(env, aiReviewLockKey(repoFullName, prNumber, headSha, mode), ownerToken);
144+
const key = aiReviewLockKey(repoFullName, prNumber, headSha, mode);
145+
await releaseTransientLockIfOwner(env, key, ownerToken);
146+
// #8998: this process no longer holds it -- a later shutdown must not attempt to release a key it already
147+
// gave up (harmless either way, since the compare-and-delete release is idempotent, but there is no reason
148+
// to carry a stale entry).
149+
unregisterHeldLock(key);
141150
}
142151

143152
/**

src/queue/held-lock-registry.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* #8998 — release the transient locks THIS process currently holds the instant a shutdown signal arrives,
3+
* rather than only via graceful drain or TTL expiry.
4+
*
5+
* `queue.stop()` (#9007) already lets an in-flight job finish naturally before the process exits, and a job's
6+
* own `finally` block releases whatever lock it holds as part of that. That is the right behavior when the
7+
* orchestrator's SIGKILL grace period is long enough for the drain to complete. It is not always long enough:
8+
* an AI-review LLM call can legitimately run for tens of seconds to minutes, while a common container-platform
9+
* grace period is 10-30s. When the hard kill lands before the drain finishes, the lock this process claimed —
10+
* `ai-review-lock` most consequentially, at a 1800s TTL — outlives the process that claimed it by up to that
11+
* TTL, starving every subsequent pass (scheduled or an explicit maintainer re-run) with the "already in
12+
* progress" placeholder for real time nobody is actually reviewing anything.
13+
*
14+
* This is the proactive half: a tiny process-local registry of "locks I currently hold and how to release
15+
* them", so the shutdown handler can best-effort release every one of them immediately on SIGTERM/SIGINT —
16+
* before, and independent of, whether the graceful drain itself has time to finish. Complementary to, not a
17+
* replacement for, the boot-time orphaned-lock flush (#9021): that catches whatever this process didn't get a
18+
* chance to release (a truly hard `kill -9`, which delivers no signal at all); this catches everything else.
19+
*/
20+
21+
type HeldLockRelease = () => Promise<void>;
22+
23+
const heldLocks = new Map<string, HeldLockRelease>();
24+
25+
/** Record that this process now holds `key`, with `release` as how to give it up. Call the moment a claim
26+
* actually succeeds with real ownership (a fail-open "acquired but nothing to release" claim has nothing
27+
* worth registering — see the call site's own guard). */
28+
export function registerHeldLock(key: string, release: HeldLockRelease): void {
29+
heldLocks.set(key, release);
30+
}
31+
32+
/** Record that this process no longer holds `key` (its own release already ran). A shutdown racing the same
33+
* release is harmless either way: `releaseTransientLockIfOwner`'s compare-and-delete makes a second release
34+
* attempt against an already-released key a safe no-op. */
35+
export function unregisterHeldLock(key: string): void {
36+
heldLocks.delete(key);
37+
}
38+
39+
/** Best-effort release every lock currently on record, clearing the registry as it goes. Never throws: a
40+
* release failure here just means that one lock rides out its own TTL, exactly the pre-#8998 behavior for
41+
* every lock, rather than blocking the rest of shutdown. Returns the count attempted, for one shutdown log
42+
* line — not the count that definitely succeeded, since a released key is removed from the registry either
43+
* way and there is no further signal to distinguish the two once shutdown is already underway. */
44+
export async function releaseAllHeldLocksAtShutdown(): Promise<number> {
45+
const entries = [...heldLocks.entries()];
46+
heldLocks.clear();
47+
let attempted = 0;
48+
for (const [, release] of entries) {
49+
attempted += 1;
50+
await release().catch(() => undefined);
51+
}
52+
return attempted;
53+
}
54+
55+
/** Test-only introspection — never used by production code. */
56+
export function heldLockCountForTest(): number {
57+
return heldLocks.size;
58+
}

src/queue/job-dispatch.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { runRetentionPrune } from "./retention";
5353
import { sweepStaleApprovalQueue } from "../services/agent-approval-queue";
5454
import { reconcileMissingPrOutcomes } from "../review/pr-outcome-reconciler";
5555
import { sweepStrandedPendingClosures } from "../review/pending-closure-watchdog";
56+
import { reconcileSurfaceWithoutDisposition } from "../review/surface-disposition-reconciler";
5657
// The 15 handlers below have no reason to move -- each is only reachable via this dispatcher (or, for
5758
// mapWithConcurrency, ALSO used by other still-in-processors.ts code), so they stay put and are exported
5859
// there purely for this one-directional import-back (processors.ts itself never calls processJob).
@@ -275,7 +276,7 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
275276
}
276277
case "agent-regate-sweep":
277278
if (!message.repoFullName && message.requestedBy !== "test") {
278-
// Three bounded repair scans ride the sweep's own fan-out tick rather than each earning a job type and
279+
// Four bounded repair scans ride the sweep's own fan-out tick rather than each earning a job type and
279280
// a cron entry. All are best-effort and deliberately BEFORE the fan-out: none may cost the tick its
280281
// re-gate work, which is the sweep's actual job.
281282
//
@@ -287,6 +288,11 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
287288
//
288289
// #9031 re-enqueues a pending-closure Pass 2 whose single delayed job was lost, which otherwise strands
289290
// the PR permanently: flagged, un-mergeable, un-approvable, and sweep-ineligible.
291+
//
292+
// #8997 re-enqueues a regate for a PR whose published surface has no matching disposition marker for
293+
// that exact head -- the "decisive panel, PR still open" shape a restart killing the pass between
294+
// publish and disposition leaves behind, which the ordinary stale-surface repair check cannot see
295+
// because the surface itself is not stale.
290296
const staleness = await sweepStaleApprovalQueue(env).catch(() => null);
291297
if (staleness && (staleness.reminded > 0 || staleness.expired > 0)) {
292298
console.log(JSON.stringify({ event: "approval_queue_staleness_swept", ...staleness }));
@@ -299,6 +305,10 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
299305
if (stranded && stranded.requeued > 0) {
300306
console.log(JSON.stringify({ event: "pending_closure_verifications_requeued", ...stranded }));
301307
}
308+
const orphanedDispositions = await reconcileSurfaceWithoutDisposition(env).catch(() => null);
309+
if (orphanedDispositions && orphanedDispositions.requeued > 0) {
310+
console.log(JSON.stringify({ event: "surface_without_disposition_reconciled", ...orphanedDispositions }));
311+
}
302312
await fanOutAgentRegateSweepJobs(env, message.requestedBy);
303313
return;
304314
}

src/queue/processors.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,7 @@ import type {
682682
} from "../types";
683683
import { sha256Hex } from "../utils/crypto";
684684
import { errorMessage, nowIso, repoParts } from "../utils/json";
685+
import { DISPOSITION_CONSIDERED_EVENT_TYPE } from "../review/surface-disposition-reconciler";
685686
import { maybeSuggestMilestoneMatchForPr } from "../integrations/project-tracker-adapter";
686687

687688
const OFFICIAL_MINER_DETECTION_TTL_MS = 5 * 60 * 1000;
@@ -1157,6 +1158,24 @@ async function isRegateRepairExhausted(env: Env, repoFullName: string, pr: Pick<
11571158
return true;
11581159
}
11591160

1161+
/** Record that a real disposition attempt began for this exact head (#8997) -- the signal
1162+
* reconcileSurfaceWithoutDisposition (surface-disposition-reconciler.ts) reads to tell "a restart killed the
1163+
* pass right after publish, before disposition ever started" apart from "disposition genuinely ran (or is
1164+
* still running) for this head". Written the moment maintenance CLAIMS its per-PR actuation lock -- i.e. the
1165+
* moment a real attempt begins, before anything about WHAT it decides. Best-effort: a failed write here must
1166+
* never block the maintenance pass it is only bookkeeping for -- worst case, a missed write costs one extra
1167+
* (safe, actuation-lock-deduplicated) repair dispatch on a later sweep tick. */
1168+
async function recordDispositionConsidered(env: Env, repoFullName: string, prNumber: number, headSha: string): Promise<void> {
1169+
await recordAuditEvent(env, {
1170+
eventType: DISPOSITION_CONSIDERED_EVENT_TYPE,
1171+
actor: "loopover",
1172+
targetKey: `${repoFullName}#${prNumber}#${headSha}`,
1173+
outcome: "completed",
1174+
detail: "maintenance actuation lock claimed; disposition attempt begins for this head",
1175+
metadata: { repoFullName, prNumber, headSha },
1176+
}).catch(() => undefined);
1177+
}
1178+
11601179
export async function surfaceRepairPriorityPullNumbers(
11611180
env: Env,
11621181
repoFullName: string,
@@ -2500,6 +2519,11 @@ async function maybeRunAgentMaintenance(
25002519
}).catch(() => undefined);
25012520
throw new PrActuationLockContendedError(repoFullName, pr.number, "agent-maintenance");
25022521
}
2522+
// #8997: the lock is now genuinely held -- a real disposition attempt for this exact head begins here. See
2523+
// recordDispositionConsidered's own doc comment for why this specific point is what the boot-time repair
2524+
// sweep checks for. Absent headSha (a sparse/never-synced PR row) has nothing to key the marker on and is
2525+
// skipped -- the pre-existing lastPublishedSurfaceSha check already covers a PR with no resolvable head.
2526+
if (pr.headSha) await recordDispositionConsidered(env, repoFullName, pr.number, pr.headSha);
25032527
try {
25042528
await runAgentMaintenancePlanAndExecute(env, {
25052529
installationId,
@@ -10148,9 +10172,26 @@ async function maybePublishPrPublicSurface(
1014810172
// #4889: per-repo admin mode swaps the global-allowlist grant for the live per-repo permission.
1014910173
(await isPerTenantAdmin(env, installationId, repoFullName, author)) ||
1015010174
isProtectedAutomationAuthor(author, env));
10175+
// #8999: exempt the freeze when the label's own provenance -- as far as this pass can tell -- is the
10176+
// #9009 lock-contention marker and NOTHING else. Without this, the freeze deadlocked: a lost AI-review lock
10177+
// race applies this exact label (aiReviewLockContendedResult's hold), the freeze then reads that SAME label
10178+
// on the very next pass and refuses a FRESH AI review (only replay), so the pass that could produce the
10179+
// real verdict needed to auto-clear the label (#9009, below in runAgentMaintenancePlanAndExecute) never
10180+
// runs -- the label outlives the transient contention that caused it, requiring a human to unstick a PR
10181+
// that was never substantively held. Reading the SAME marker key #9009 already writes/reads keeps this a
10182+
// single source of truth for "why is this label here right now" rather than a second, drifting one.
10183+
//
10184+
// This does not reopen the gaming surface the freeze exists to close (see #regate-churn doc above): that
10185+
// surface is a CONTRIBUTOR iterating pushes to game the bot. A lock-contention hold is not a contributor
10186+
// action -- it is an infra artifact of two ORB passes racing -- so exempting it changes nothing about a
10187+
// contributor's ability to buy a fresh verdict by pushing. If some OTHER reason also justifies the hold,
10188+
// that reason re-applies the label on this very pass's own disposition regardless of this exemption.
10189+
const manualReviewLockContentionMarkerKey = `manual-review-lock-contention:${repoFullName.toLowerCase()}#${pr.number}`;
10190+
const manualReviewLabelIsPurelyLockContention = manualReviewLabel !== null && (await getTransientKey(env, manualReviewLockContentionMarkerKey)) !== null;
1015110191
const isFrozenForManualReview =
1015210192
webhook.forceAiReview !== true &&
1015310193
!authorIsExemptFromFreeze &&
10194+
!manualReviewLabelIsPurelyLockContention &&
1015410195
manualReviewLabel !== null &&
1015510196
pr.labels.some((label) => label.toLowerCase() === manualReviewLabel.toLowerCase());
1015610197
let reviewManifestForAutoReview: FocusManifest | null = null;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { recordAuditEvent } from "../db/repositories";
2+
import { errorMessage } from "../utils/json";
3+
4+
/**
5+
* #8997 — rescue a PR left wearing a decisive panel with no matching disposition.
6+
*
7+
* A deploy restart (SIGTERM, no drain) can kill a pass at the worst possible cut point: after the public-surface
8+
* publish commits (panel comment, gate check-run, CI aggregate all reflect the current head) but before
9+
* `maybeRunAgentMaintenance` ever claims its per-PR actuation lock for that head. The confirmed live shape
10+
* (#8965, 2026-07-26): a red-CI panel published at 14:55:24Z from the dying container, and the matching close
11+
* only executed at 15:07:53Z — ~12 minutes later, purely because an UNRELATED later sweep tick happened to
12+
* re-run the whole pass from scratch. The regular outage-repair priority check
13+
* (`surfaceRepairPriorityPullNumbers`, processors.ts) cannot see this: it flags a STALE surface
14+
* (`lastPublishedSurfaceSha !== headSha`), and this incident's surface is not stale — it published successfully
15+
* right before the kill. From that check's point of view the PR already looks healthy.
16+
*
17+
* This scan is the missing half: it looks for the disposition side directly. `maybeRunAgentMaintenance` records
18+
* a durable marker (`DISPOSITION_CONSIDERED_EVENT_TYPE`, processors.ts) the moment it claims the actuation lock
19+
* for a head — i.e. the moment a real disposition attempt begins. A PR whose surface is current for its head but
20+
* carries no such marker never got that far, for whatever reason (a restart being the confirmed one; a thrown
21+
* error before the claim is another). Re-enqueuing a regate is cheap and safe either way: `agent-regate-pr`
22+
* re-derives everything from live state, and a PR that in fact already has a real disposition in flight is
23+
* simply denied by the SAME actuation lock this scan is checking for, exactly as any other contending pass would
24+
* be (#9013's per-PR mutex).
25+
*
26+
* Deliberately independent of surfaceRepairPriorityPullNumbers rather than folded into it: that function's
27+
* return value directly drives the regular sweep's staleness-ordered fan-out and is asserted against by a large
28+
* existing test surface (dispatch shape, ordering, backlog-restriction semantics). Riding the SAME sweep tick as
29+
* its own bounded scan — mirroring reconcileMissingPrOutcomes/sweepStrandedPendingClosures (#9026/#9031) — closes
30+
* the identical gap without touching that already-tested surface at all.
31+
*/
32+
33+
export const DISPOSITION_CONSIDERED_EVENT_TYPE = "agent.maintenance.disposition_considered";
34+
35+
/** How far back to look. Bounded so the scan stays cheap on every tick; a surface-without-disposition PR older
36+
* than this has almost certainly been superseded by a later commit or resolved by a human already. */
37+
export const SURFACE_DISPOSITION_RECONCILE_LOOKBACK_MS = 24 * 60 * 60 * 1000;
38+
39+
/** Cap per run, mirroring the other bounded repair scans on this same sweep tick. */
40+
export const SURFACE_DISPOSITION_RECONCILE_LIMIT = 200;
41+
42+
export type SurfaceDispositionReconcileResult = { scanned: number; requeued: number };
43+
44+
/**
45+
* Find open PRs with a current published surface but no disposition marker for that exact head, and re-enqueue
46+
* an `agent-regate-pr` job for each. Best-effort throughout: a repair pass that can itself break the tick it
47+
* rides on is worse than the gap it exists to close.
48+
*/
49+
export async function reconcileSurfaceWithoutDisposition(env: Env, nowMs: number = Date.now()): Promise<SurfaceDispositionReconcileResult> {
50+
const since = new Date(nowMs - SURFACE_DISPOSITION_RECONCILE_LOOKBACK_MS).toISOString();
51+
let rows: Array<{ repoFullName: string; number: number; installationId: number; headSha: string }> = [];
52+
try {
53+
const result = await env.DB.prepare(
54+
`SELECT pr.repo_full_name AS repoFullName, pr.number AS number, repo.installation_id AS installationId, pr.head_sha AS headSha
55+
FROM pull_requests AS pr
56+
JOIN repositories AS repo ON repo.full_name = pr.repo_full_name
57+
WHERE pr.state = 'open'
58+
AND pr.head_sha IS NOT NULL
59+
AND pr.last_published_surface_sha = pr.head_sha
60+
AND pr.updated_at >= ?1
61+
AND repo.installation_id IS NOT NULL
62+
AND NOT EXISTS (
63+
SELECT 1 FROM audit_events AS disposition
64+
WHERE disposition.target_key = pr.repo_full_name || '#' || pr.number || '#' || pr.head_sha
65+
AND disposition.event_type = ?2
66+
)
67+
ORDER BY pr.updated_at
68+
LIMIT ?3`,
69+
)
70+
.bind(since, DISPOSITION_CONSIDERED_EVENT_TYPE, SURFACE_DISPOSITION_RECONCILE_LIMIT)
71+
.all<{ repoFullName: string; number: number; installationId: number; headSha: string }>();
72+
rows = result.results ?? [];
73+
} catch (error) {
74+
console.warn(JSON.stringify({ level: "warn", event: "surface_disposition_reconcile_scan_failed", message: errorMessage(error).slice(0, 160) }));
75+
return { scanned: 0, requeued: 0 };
76+
}
77+
78+
let requeued = 0;
79+
for (const row of rows) {
80+
const sent = await env.JOBS.send({
81+
type: "agent-regate-pr",
82+
deliveryId: `surface-without-disposition:${row.repoFullName}#${row.number}#${row.headSha}`,
83+
repoFullName: row.repoFullName,
84+
prNumber: row.number,
85+
installationId: row.installationId,
86+
})
87+
.then(() => true)
88+
.catch(() => false);
89+
if (!sent) continue;
90+
requeued += 1;
91+
await recordAuditEvent(env, {
92+
eventType: "agent.sweep.surface_without_disposition",
93+
actor: "loopover",
94+
targetKey: `${row.repoFullName}#${row.number}`,
95+
outcome: "queued",
96+
detail: "published surface has no matching disposition for this head; re-gating to close the gap",
97+
metadata: { repoFullName: row.repoFullName, pullNumber: row.number, headSha: row.headSha },
98+
}).catch(() => undefined);
99+
}
100+
return { scanned: rows.length, requeued };
101+
}

0 commit comments

Comments
 (0)