Skip to content

ClawSweeper does not review fresh openclaw/openclaw PRs: per-target exact-review cap of 1 (PR #348) starves new PRs #349

Description

@yetval

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:

PR opened (UTC) clawsweeper review comments at 22:03Z
openclaw/openclaw#95852 20:14 0 (open ~1h50m)
openclaw/openclaw#95860 20:56 0 (open ~1h7m)

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

  1. Make DEFAULT_EXACT_REVIEW_TARGET_MAX_CONCURRENT configurable per target and set openclaw/openclaw to at least the global cap (smallest change, quickest mitigation).
  2. 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.
  3. Reserve a fresh-PR slice in the scheduled broad sweep so a saturated backlog still reaches the newest PRs (fetchOpenItemPage ordering plus shouldStopSaturatedPlanScan).
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Urgent regression or broken agent/channel workflow affecting real users now.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions