Summary
ClawSweeper is not reviewing freshly opened openclaw/openclaw PRs. The bot is alive and keeps re-reviewing older PRs, but brand-new PRs go unreviewed for hours. Root cause: PR #348 introduced a per-target exact-review concurrency cap of 1, which serializes all openclaw/openclaw reviews onto a single slot held under a 130 minute lease. On the busiest repo, re-review and retry churn keeps that single slot occupied, so new PRs wait behind the entire backlog.
Impact
Observed on 2026-06-22:
About a dozen other PRs opened in the same window were also unreviewed (#95864, #95863, #95862, #95859, #95857, #95856, #95855, #95854, #95848, #95844, #95842), while the dispatch stream was dominated by repeat re-reviews of old PRs (#73079, #91502, #94272 each dispatched multiple times). This is throughput starvation of new PRs, not an outage and not an author block (the same author's prior PRs #95614 and #95432 were reviewed normally).
Root cause
Newly opened PRs are reviewed through the event-driven exact-review path: webhook to ExactReviewQueue (dashboard/worker.ts) to a per-item repository_dispatch. The admission loop in ExactReviewQueue.alarm() enforces a per-target cap:
// dashboard/worker.ts, alarm()
for (const item of pending) { // pending sorted by createdAt ASC
if (admitted.length >= slots) break;
const active = activeTargets.get(item.decision.targetRepo) || 0;
if (active >= DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT) continue; // cap
...
}
with (dashboard/worker.ts:74-77):
const DEFAULT_EXACT_REVIEW_QUEUE_MAX_CONCURRENT = 4; // global
const DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT = 1; // per target repo
const DEFAULT_EXACT_REVIEW_EXECUTION_LEASE_MS = 130 * 60 * 1000;
DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT = 1 and its enforcement line are new in PR #348 (fix(ci): bound ClawSweeper review fanout). The same commit also dropped the global queue cap 8 to 4 and WORKER_BUDGET 64 to 32:
git show f69d9de -- dashboard/worker.ts
-const DEFAULT_EXACT_REVIEW_QUEUE_MAX_CONCURRENT = 8;
+const DEFAULT_EXACT_REVIEW_QUEUE_MAX_CONCURRENT = 4;
+const DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT = 1;
...
+ if (active >= DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT) continue;
Because every openclaw/openclaw PR and issue shares the same targetRepo, the per-target cap means at most one openclaw exact review runs at a time regardless of free global slots. The slot is held up to 130 minutes and is constantly re-occupied by re-review and retry churn of older items, and pending items admit oldest-enqueued-first, so new PRs starve. Before #348 there was no per-target cap (up to 8 concurrent).
This is compounded by the scheduled lanes failing to rescue missed PRs: the broad sweep pages open items created asc and stops at shouldStopSaturatedPlanScan once the due backlog fills capacity (now much smaller after config/automation-limits.json cut workers 128 to 32, exact_review 32 to 4, and sweep.yml shard_count 89 to 22), so it re-reviews the oldest items and never reaches the newest. selectDueCandidates also prepends all weekly-overdue items first, leaving zero slots for the hot_pull_request bucket where fresh PRs live when the overdue backlog exceeds capacity.
Each piece is an intentional throttle, not an item-dropping bug, but together they make the time-to-first-review for a fresh openclaw PR far longer than its actionable window. The single highest-leverage line is the per-target cap.
Reproduction
Drop this test in test/ on current main and run node --test test/repro-fresh-pr-starvation.test.ts. It drives the real ExactReviewQueue:
import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto";
import test from "node:test";
import { ExactReviewQueue } from "../dashboard/worker.ts";
class MemoryDurableStorage {
values = new Map(); alarmAt = null;
async get(k) { return this.values.get(k); }
async put(k, v) { this.values.set(k, v); }
async delete(k) { this.values.delete(k); }
async list() { return new Map(this.values); }
async getAlarm() { return this.alarmAt; }
async setAlarm(a) { this.alarmAt = a; }
async deleteAlarm() { this.alarmAt = null; }
}
function enqueue(id, n, repo) {
return new Request("https://q/enqueue", { method: "POST", body: JSON.stringify({
delivery_id: id, decision: { targetRepo: repo, targetBranch: "main", itemNumber: n,
itemKind: "pull_request", sourceEvent: "pull_request", sourceAction: "opened",
supersedesInProgress: false } }) });
}
test("backlog of openclaw PRs only releases one review per cycle", async () => {
const dispatched = [];
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" } });
const orig = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const u = new URL(String(input));
if (u.pathname === "/repos/openclaw/clawsweeper/installation") return new Response(JSON.stringify({ id: 999 }));
if (u.pathname === "/app/installations/999/access_tokens") return new Response(JSON.stringify({ token: "t" }));
if (u.pathname === "/repos/openclaw/clawsweeper/dispatches") { dispatched.push(JSON.parse(String(init.body))); return new Response(null, { status: 204 }); }
throw new Error("unexpected " + u);
};
try {
const q = new ExactReviewQueue({ storage: new MemoryDurableStorage() },
{ CLAWSWEEPER_APP_CLIENT_ID: "Iv23", CLAWSWEEPER_APP_PRIVATE_KEY: privateKey,
EXACT_REVIEW_QUEUE_MAX_CONCURRENT: "8" }); // pre-#348 global headroom
for (const n of [95812, 95843, 95852, 95858, 95860]) await q.fetch(enqueue("d" + n, n, "openclaw/openclaw"));
await q.fetch(enqueue("dg", 12, "openclaw/gogcli")); // contrast target
await q.alarm();
const t = dispatched.map(p => p.client_payload.target_repo);
assert.equal(t.filter(x => x === "openclaw/openclaw").length, 1); // only 1 openclaw PR despite free slots
assert.equal(t.filter(x => x === "openclaw/gogcli").length, 1); // different target unaffected
assert.ok(!dispatched.some(p => p.client_payload.item_number === 95860)); // fresh PR starved
} finally { globalThis.fetch = orig; }
});
Result: with 8 global slots free and 5 openclaw PRs pending, only 1 openclaw PR dispatches per cycle (the fresh #95860 is left waiting), while a different-target item dispatches in the same cycle. A second alarm() with no completion dispatches no further openclaw review (the slot stays leased up to 130 minutes). This isolates the per-target cap, not global exhaustion, as the cause.
Suggested fix
- Make
DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT configurable per target and set openclaw/openclaw to at least the global cap (smallest change, quickest mitigation).
- Or exempt freshly
opened PRs from the per-target cap (or give them a small reservation) so re-review churn of old items cannot crowd out new PRs.
- Reserve a fresh-PR slice in the scheduled broad sweep so a saturated backlog still reaches the newest PRs (
fetchOpenItemPage ordering plus shouldStopSaturatedPlanScan).
- Reconsider the global cap 8 to 4 and worker budget 64 to 32 if API pressure allows, since the throttle was tuned for total API load rather than per-repo review latency.
Related
Summary
ClawSweeper is not reviewing freshly opened
openclaw/openclawPRs. The bot is alive and keeps re-reviewing older PRs, but brand-new PRs go unreviewed for hours. Root cause: PR #348 introduced a per-target exact-review concurrency cap of 1, which serializes allopenclaw/openclawreviews onto a single slot held under a 130 minute lease. On the busiest repo, re-review and retry churn keeps that single slot occupied, so new PRs wait behind the entire backlog.Impact
Observed on 2026-06-22:
About a dozen other PRs opened in the same window were also unreviewed (#95864, #95863, #95862, #95859, #95857, #95856, #95855, #95854, #95848, #95844, #95842), while the dispatch stream was dominated by repeat re-reviews of old PRs (#73079, #91502, #94272 each dispatched multiple times). This is throughput starvation of new PRs, not an outage and not an author block (the same author's prior PRs #95614 and #95432 were reviewed normally).
Root cause
Newly opened PRs are reviewed through the event-driven exact-review path: webhook to
ExactReviewQueue(dashboard/worker.ts) to a per-itemrepository_dispatch. The admission loop inExactReviewQueue.alarm()enforces a per-target cap:with (
dashboard/worker.ts:74-77):DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT = 1and its enforcement line are new in PR #348 (fix(ci): bound ClawSweeper review fanout). The same commit also dropped the global queue cap 8 to 4 andWORKER_BUDGET64 to 32:Because every
openclaw/openclawPR and issue shares the sametargetRepo, the per-target cap means at most one openclaw exact review runs at a time regardless of free global slots. The slot is held up to 130 minutes and is constantly re-occupied by re-review and retry churn of older items, and pending items admit oldest-enqueued-first, so new PRs starve. Before #348 there was no per-target cap (up to 8 concurrent).This is compounded by the scheduled lanes failing to rescue missed PRs: the broad sweep pages open items
created ascand stops atshouldStopSaturatedPlanScanonce the due backlog fills capacity (now much smaller afterconfig/automation-limits.jsoncut workers 128 to 32, exact_review 32 to 4, andsweep.ymlshard_count 89 to 22), so it re-reviews the oldest items and never reaches the newest.selectDueCandidatesalso prepends all weekly-overdue items first, leaving zero slots for thehot_pull_requestbucket where fresh PRs live when the overdue backlog exceeds capacity.Each piece is an intentional throttle, not an item-dropping bug, but together they make the time-to-first-review for a fresh openclaw PR far longer than its actionable window. The single highest-leverage line is the per-target cap.
Reproduction
Drop this test in
test/on currentmainand runnode --test test/repro-fresh-pr-starvation.test.ts. It drives the realExactReviewQueue:Result: with 8 global slots free and 5 openclaw PRs pending, only 1 openclaw PR dispatches per cycle (the fresh #95860 is left waiting), while a different-target item dispatches in the same cycle. A second
alarm()with no completion dispatches no further openclaw review (the slot stays leased up to 130 minutes). This isolates the per-target cap, not global exhaustion, as the cause.Suggested fix
DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENTconfigurable per target and setopenclaw/openclawto at least the global cap (smallest change, quickest mitigation).openedPRs from the per-target cap (or give them a small reservation) so re-review churn of old items cannot crowd out new PRs.fetchOpenItemPageordering plusshouldStopSaturatedPlanScan).Related