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
17 changes: 16 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1368,7 +1368,7 @@ async function refreshOpenPullRequestsForScheduledSweep(
// head SHA, which resets the count naturally (the target key is scoped to repo+PR+SHA).
const REGATE_REPAIR_ATTEMPT_EVENT_TYPE = "agent.sweep.regate.repair_attempt";
const REGATE_REPAIR_EXHAUSTED_EVENT_TYPE = "agent.sweep.regate.repair_exhausted";
const REGATE_REPAIR_MAX_ATTEMPTS_PER_SHA = 3;
const REGATE_REPAIR_MAX_ATTEMPTS_PER_SHA = 2;
const REGATE_REPAIR_ATTEMPT_LOOKBACK_MS = 24 * 60 * 60 * 1000;

function regateRepairTargetKey(repoFullName: string, prNumber: number, headSha: string): string {
Expand Down Expand Up @@ -1435,6 +1435,21 @@ async function surfaceRepairPriorityPullNumbers(
detail: `re-gate repair exhausted after ${attempts} attempt(s) for the same head SHA; falling back to ordinary staleness cadence`,
metadata: { repoFullName, prNumber: pr.number, headSha: pr.headSha, attempts },
});
// level:"error" is deliberate, not a code failure: this line only fires once the cap above already
// stopped the wasteful repair loop, so its OWN existence is the operator-visible signal (via the
// structured log → Sentry forwarder, forwardStructuredLogToSentry) that a PR kept failing repair for the
// same head SHA — the same "surface an anomaly at error level" convention selfhost_ai_provider_failed /
// selfhost_ai_providers_exhausted already use in src/selfhost/ai.ts.
console.error(
JSON.stringify({
level: "error",
event: "regate_repair_exhausted",
repo: repoFullName,
pullNumber: pr.number,
headSha: pr.headSha,
attempts,
}),
);
}),
);
return [...priorityPullNumbers];
Expand Down
40 changes: 25 additions & 15 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6284,22 +6284,32 @@ describe("queue processors", () => {
});
}
vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z"));
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);

await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" });

// No longer treated as priority repair -- either not fanned at all, or fanned as an ordinary "regate-sweep:"
// candidate, but never re-dispatched as "regate-repair:" once the same SHA has exhausted its attempt budget.
const fanned = sent.filter((job): job is Extract<import("../../src/types").JobMessage, { type: "agent-regate-pr" }> => job.type === "agent-regate-pr");
expect(fanned.every((job) => job.deliveryId !== "regate-repair:owner/agent-repo#1")).toBe(true);
const exhausted = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
.bind("agent.sweep.regate.repair_exhausted", targetKey)
.first<{ n: number }>();
expect(exhausted?.n).toBe(1);
// No further repair-attempt event was recorded for the exhausted SHA this tick.
const attempts = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
.bind("agent.sweep.regate.repair_attempt", targetKey)
.first<{ n: number }>();
expect(attempts?.n).toBe(3);
try {
await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" });

// No longer treated as priority repair -- either not fanned at all, or fanned as an ordinary "regate-sweep:"
// candidate, but never re-dispatched as "regate-repair:" once the same SHA has exhausted its attempt budget.
const fanned = sent.filter((job): job is Extract<import("../../src/types").JobMessage, { type: "agent-regate-pr" }> => job.type === "agent-regate-pr");
expect(fanned.every((job) => job.deliveryId !== "regate-repair:owner/agent-repo#1")).toBe(true);
const exhausted = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
.bind("agent.sweep.regate.repair_exhausted", targetKey)
.first<{ n: number }>();
expect(exhausted?.n).toBe(1);
// No further repair-attempt event was recorded for the exhausted SHA this tick.
const attempts = await env.DB.prepare("select count(*) as n from audit_events where event_type = ? and target_key = ?")
.bind("agent.sweep.regate.repair_attempt", targetKey)
.first<{ n: number }>();
expect(attempts?.n).toBe(3);
// Sentry-visible signal (via the structured-log forwarder) fires exactly once alongside the audit event.
const exhaustedLogs = errors.mock.calls.filter(([line]) => typeof line === "string" && line.includes("regate_repair_exhausted"));
expect(exhaustedLogs).toHaveLength(1);
const logged = JSON.parse(exhaustedLogs[0]![0] as string) as Record<string, unknown>;
expect(logged).toMatchObject({ level: "error", event: "regate_repair_exhausted", repo: "owner/agent-repo", pullNumber: 1, headSha: "stuck-sha", attempts: 3 });
} finally {
errors.mockRestore();
}
});

it("REGRESSION (#orb-retry-storm): a repair dispatch under the attempt cap records a repair_attempt audit event, and a second sweep tick does not duplicate the repair_exhausted event once already flagged", async () => {
Expand Down