Skip to content

Commit 1566ad6

Browse files
authored
fix(ci): reduce ClawSweeper GitHub API pressure (#343)
1 parent 471bf80 commit 1566ad6

8 files changed

Lines changed: 86 additions & 93 deletions

File tree

.github/workflows/commit-review.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,7 @@ jobs:
202202
active_shards=0
203203
fi
204204
# Planning, publish, queued, and not-yet-expanded matrix runs have not
205-
# created shard jobs yet. Reserve their quiet lane size so commit review
206-
# does not over-allocate while a sweep matrix is still expanding.
205+
# created shard jobs yet. Reserve their quiet lane size while they expand.
207206
if [ "$active_shards" -lt 1 ]; then
208207
if [ "$title" = "Review hot ClawSweeper items" ]; then
209208
active_shards="$(pnpm --dir clawsweeper run --silent workflow -- limit review_shards.hot_intake_default)"

.github/workflows/github-activity.yml

Lines changed: 8 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -91,53 +91,6 @@ jobs:
9191
echo "skip=false" >> "$GITHUB_OUTPUT"
9292
fi
9393
94-
- name: Dispatch spam comment intake candidates
95-
if: steps.core-budget.outputs.skip != 'true'
96-
env:
97-
GH_TOKEN: ${{ github.token }}
98-
shell: bash
99-
run: |
100-
set -euo pipefail
101-
payload_path="$RUNNER_TEMP/spam-comment-intake-dispatch.json"
102-
node <<'NODE' > "$payload_path"
103-
const fs = require("fs");
104-
const eventName = process.env.GITHUB_EVENT_NAME || "";
105-
const root = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
106-
const clientPayload = root.client_payload || {};
107-
const activity = clientPayload.activity || clientPayload || {};
108-
const type = activity.type || clientPayload.event_name || (eventName === "repository_dispatch" ? "" : eventName);
109-
const action = activity.action || clientPayload.action || root.action || "";
110-
if (!["issue_comment", "pull_request_review_comment"].includes(type)) process.exit(0);
111-
if (!["created", "edited"].includes(action)) process.exit(0);
112-
const repo =
113-
activity.target_repo ||
114-
activity.repo ||
115-
(activity.repository && activity.repository.full_name) ||
116-
(typeof activity.repository === "string" && activity.repository) ||
117-
clientPayload.target_repo ||
118-
clientPayload.repo ||
119-
(clientPayload.repository && clientPayload.repository.full_name) ||
120-
(typeof clientPayload.repository === "string" && clientPayload.repository) ||
121-
(root.repository && root.repository.full_name) ||
122-
process.env.GITHUB_REPOSITORY;
123-
process.stdout.write(JSON.stringify({
124-
event_type: "clawsweeper_spam_comment_intake",
125-
client_payload: {
126-
target_repo: repo,
127-
event_name: type,
128-
action,
129-
activity: { ...activity, type, action, repo },
130-
},
131-
}));
132-
NODE
133-
if [ ! -s "$payload_path" ]; then
134-
echo "No spam comment intake candidate for this activity."
135-
exit 0
136-
fi
137-
gh api "repos/${GITHUB_REPOSITORY}/dispatches" \
138-
--method POST \
139-
--input "$payload_path"
140-
14194
- uses: actions/checkout@v6
14295
if: steps.core-budget.outputs.skip != 'true'
14396
with:
@@ -195,6 +148,14 @@ jobs:
195148
done
196149
pnpm run build:repair
197150
151+
- name: Dispatch spam scan candidate
152+
if: steps.core-budget.outputs.skip != 'true'
153+
env:
154+
GH_TOKEN: ${{ github.token }}
155+
run: |
156+
set -euo pipefail
157+
pnpm run repair:spam-comment-intake -- --write-report
158+
198159
- name: Feed activity to OpenClaw
199160
if: steps.core-budget.outputs.skip != 'true'
200161
env:

.github/workflows/sweep.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,8 +994,7 @@ jobs:
994994
active_shards=0
995995
fi
996996
# Planning, publish, queued, and not-yet-expanded matrix runs have not
997-
# created shard jobs yet. Reserve their quiet lane size so a second
998-
# planner cannot over-allocate before the first matrix expands.
997+
# created shard jobs yet. Reserve their quiet lane size while they expand.
999998
if [ "$active_shards" -lt 1 ]; then
1000999
if [ "$title" = "Review hot ClawSweeper items" ] || [[ "$title" == Review\ hot\ target\ repo\ * ]]; then
10011000
active_shards="$(limit review_shards.hot_intake_default)"

docs/target-dispatcher.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,45 @@ The event job creates only a target read token before Codex runs. The target
230230
write token and the repository push token are introduced after Codex exits, and
231231
the same `apply-decisions` guard path still re-fetches the item before any
232232
comment or close mutation.
233+
234+
## Rate-limit-safe CI setup
235+
236+
Install one dispatcher workflow per target repository. Keep the event fanout
237+
inside that workflow; do not add separate comment, spam, or generic-activity
238+
dispatch workflows in the target repository.
239+
240+
The snippet below is only the trigger, permission, and concurrency fragment.
241+
It is not a complete workflow; use the full dispatcher example above as the
242+
copy-pasteable job definition.
243+
244+
```yaml
245+
name: ClawSweeper Dispatch
246+
247+
on:
248+
issues:
249+
types: [opened, reopened, edited, labeled, unlabeled]
250+
issue_comment:
251+
types: [created, edited]
252+
pull_request_target:
253+
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
254+
255+
permissions:
256+
contents: read
257+
258+
concurrency:
259+
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
260+
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
261+
```
262+
263+
The job should mint one short-lived `clawsweeper` App token scoped to
264+
`openclaw/clawsweeper`, then send one `clawsweeper_item` or
265+
`clawsweeper_comment` `repository_dispatch`. For comments, check the command
266+
syntax before minting a target write token. Do not use a PAT or dispatch the
267+
same comment through both the exact router and a second spam/generic workflow.
268+
269+
The ClawSweeper `github-activity` workflow performs spam-candidate
270+
classification in-process and only dispatches the scanner for an accepted
271+
candidate. This keeps ordinary comments to one activity run instead of an
272+
activity run plus a second intake workflow. Preserve the source delivery or
273+
comment id in every payload so receiver-side deduplication can collapse
274+
redeliveries.

src/clawsweeper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export { itemNumbersArg } from "./clawsweeper-args.js";
9191
export { safeOutputTail } from "./clawsweeper-text.js";
9292
export {
9393
ghRetryKind,
94+
ghRetryWaitMs,
9495
isGitHubNotFoundError,
9596
isGitHubRequiresAuthenticationError,
9697
isLockedConversationCommentError,

src/github-retry.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ const GH_THROTTLE_PATTERNS = [
44
/was submitted too quickly/i,
55
/secondary rate/i,
66
/API rate limit exceeded/i,
7+
/rate limit/i,
78
];
89

910
const GH_TRANSIENT_PATTERNS = [
1011
/unexpected EOF/i,
12+
/connection reset/i,
1113
/connection reset by peer/i,
1214
/error connecting to api\.github\.com/i,
1315
/bad gateway/i,
@@ -20,12 +22,18 @@ const GH_TRANSIENT_PATTERNS = [
2022
/TLS handshake timeout/i,
2123
/\bi\/o timeout\b/i,
2224
/Client\.Timeout exceeded/i,
25+
/connection refused/i,
26+
/could not resolve host/i,
27+
/timed out/i,
28+
/\btimeout\b/i,
2329
/temporary failure/i,
30+
/try again later/i,
2431
];
2532

2633
export function ghRetryKind(error: unknown): GhRetryKind {
2734
const message = ghErrorText(error);
2835
if (GH_THROTTLE_PATTERNS.some((pattern) => pattern.test(message))) return "throttle";
36+
if (hasGitHubStatus(message, [429])) return "throttle";
2937
if (hasGitHubStatus(message, [500, 502, 503, 504])) return "transient";
3038
if (GH_TRANSIENT_PATTERNS.some((pattern) => pattern.test(message))) return "transient";
3139
return "none";
@@ -56,7 +64,7 @@ export function isGitHubRequiresAuthenticationError(error: unknown): boolean {
5664
}
5765

5866
export function ghRetryWaitMs(kind: GhRetryKind, attempt: number): number {
59-
if (kind === "throttle") return Math.min(600_000, 30_000 * 2 ** attempt);
67+
if (kind === "throttle") return Math.min(60_000, 30_000 * 2 ** attempt);
6068
if (kind === "transient") return Math.min(60_000, 2_000 * 2 ** attempt);
6169
return 0;
6270
}

src/repair/github-cli.ts

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { promisify } from "node:util";
44
import { stripAnsi } from "./comment-router-utils.js";
55
import { ghCliEnv } from "./process-env.js";
66
import { repoRoot } from "./paths.js";
7+
import { ghRetryKind, ghRetryWaitMs } from "../github-retry.js";
78

89
const execFileAsync = promisify(execFile);
910

@@ -131,8 +132,9 @@ export function ghPagedLimitWithRetry<T = JsonValue>(
131132
return ghPagedLimit<T>(apiPath, limit, resolved);
132133
} catch (error) {
133134
lastError = error;
134-
if (attempt >= attempts || !shouldRetryGh(error)) throw error;
135-
sleepMs(Math.min(1000 * attempt, 5000));
135+
const retryKind = ghRetryKind(error);
136+
if (attempt >= attempts || retryKind === "none") throw error;
137+
sleepMs(ghRetryWaitMs(retryKind, attempt - 1));
136138
}
137139
}
138140
throw lastError instanceof Error ? lastError : new Error(String(lastError));
@@ -159,8 +161,9 @@ export function ghTextWithRetry(ghArgs: string[], options: GhRetryOptions | numb
159161
return ghText(ghArgs, resolved);
160162
} catch (error) {
161163
lastError = error;
162-
if (attempt >= attempts || !shouldRetryGh(error)) throw error;
163-
sleepMs(Math.min(1000 * attempt, 5000));
164+
const retryKind = ghRetryKind(error);
165+
if (attempt >= attempts || retryKind === "none") throw error;
166+
sleepMs(ghRetryWaitMs(retryKind, attempt - 1));
164167
}
165168
}
166169
throw lastError instanceof Error ? lastError : new Error(String(lastError));
@@ -178,8 +181,9 @@ export async function ghTextWithRetryAsync(
178181
return await ghTextAsync(ghArgs, resolved);
179182
} catch (error) {
180183
lastError = error;
181-
if (attempt >= attempts || !shouldRetryGh(error)) throw error;
182-
await sleepAsync(Math.min(1000 * attempt, 5000));
184+
const retryKind = ghRetryKind(error);
185+
if (attempt >= attempts || retryKind === "none") throw error;
186+
await sleepAsync(ghRetryWaitMs(retryKind, attempt - 1));
183187
}
184188
}
185189
throw lastError instanceof Error ? lastError : new Error(String(lastError));
@@ -257,27 +261,6 @@ export function ghStdoutFromError(error: unknown): string {
257261
).trim();
258262
}
259263

260-
export function shouldRetryGh(error: unknown): boolean {
261-
const text = ghErrorText(error).toLowerCase();
262-
return (
263-
text.includes("http 502") ||
264-
text.includes("http 503") ||
265-
text.includes("http 504") ||
266-
text.includes("bad gateway") ||
267-
text.includes("gateway timeout") ||
268-
text.includes("service unavailable") ||
269-
text.includes("timed out") ||
270-
text.includes("timeout") ||
271-
text.includes("connection reset") ||
272-
text.includes("connection refused") ||
273-
text.includes("could not resolve host") ||
274-
text.includes("temporary failure") ||
275-
text.includes("try again later") ||
276-
text.includes("secondary rate limit") ||
277-
text.includes("rate limit")
278-
);
279-
}
280-
281264
function resolveRetryOptions(options: GhRetryOptions | number): GhRetryOptions {
282265
if (typeof options === "number") return { attempts: options };
283266
return options;

test/clawsweeper.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
fixedPullRequestFromCommitPullsForTest,
3131
formatRecentClosedRows,
3232
ghRetryKind,
33+
ghRetryWaitMs,
3334
hotIntakeRecencyMs,
3435
isCodexReviewCommentBody,
3536
isInfrastructureFailedReviewForTest,
@@ -13754,6 +13755,7 @@ test("review capacity probes use REST actions run listing", () => {
1375413755
assert.match(block, /updatedAt:\.updated_at/);
1375513756
assert.match(block, /STALE_QUEUED_CUTOFF/);
1375613757
assert.doesNotMatch(block, /gh run list/);
13758+
assert.match(block, /gh run view/);
1375713759
}
1375813760
});
1375913761

@@ -13949,20 +13951,10 @@ test("github activity workflow scopes cancellation to matching item activity", (
1394913951
assert.match(workflow, /Check core API budget/);
1395013952
assert.match(workflow, /CLAWSWEEPER_MIN_CORE_REMAINING/);
1395113953
assert.match(workflow, /contents: write/);
13952-
assert.match(workflow, /Dispatch spam comment intake candidates/);
13953-
assert.match(workflow, /clawsweeper_spam_comment_intake/);
13954-
assert.match(workflow, /\["issue_comment", "pull_request_review_comment"\]/);
13955-
assert.match(workflow, /\["created", "edited"\]/);
13956-
const dispatchStep = workflow.slice(
13957-
workflow.indexOf("- name: Dispatch spam comment intake candidates"),
13958-
workflow.indexOf("\n - uses: actions/checkout@v6"),
13959-
);
13960-
const dispatchScript = dispatchStep
13961-
.slice(dispatchStep.indexOf(" run: |\n") + " run: |\n".length)
13962-
.split("\n")
13963-
.map((line) => line.replace(/^ {10}/, ""))
13964-
.join("\n");
13965-
execFileSync("bash", ["-n"], { input: dispatchScript });
13954+
assert.doesNotMatch(workflow, /Dispatch spam comment intake candidates/);
13955+
assert.match(workflow, /Dispatch spam scan candidate/);
13956+
assert.match(workflow, /repair:spam-comment-intake -- --write-report/);
13957+
assert.doesNotMatch(workflow, /gh api "repos\/\$\{GITHUB_REPOSITORY\}\/dispatches"/);
1396613958
assert.match(concurrencyBlock, /cancel-in-progress: true/);
1396713959
assert.doesNotMatch(
1396813960
concurrencyBlock,
@@ -14802,6 +14794,10 @@ test("GitHub retry classifier distinguishes throttle and transient failures", ()
1480214794
const throttled = new Error("API rate limit exceeded for user ID 1");
1480314795
assert.equal(ghRetryKind(throttled), "throttle");
1480414796
assert.equal(shouldRetryGh(throttled), true);
14797+
assert.equal(ghRetryKind(new Error("gh: HTTP 429: Too Many Requests")), "throttle");
14798+
assert.equal(ghRetryWaitMs("throttle", 0), 30_000);
14799+
assert.equal(ghRetryWaitMs("throttle", 3), 60_000);
14800+
assert.equal(ghRetryWaitMs("transient", 0), 2_000);
1480514801

1480614802
const eof = Object.assign(new Error("Command failed: gh api repos/openclaw/openclaw/issues"), {
1480714803
stderr: 'Get "https://api.github.com/repos/openclaw/openclaw/issues?page=54": unexpected EOF\n',
@@ -14813,6 +14809,7 @@ test("GitHub retry classifier distinguishes throttle and transient failures", ()
1481314809
"Post https://api.github.com/graphql: read: connection reset by peer",
1481414810
);
1481514811
assert.equal(ghRetryKind(connectionReset), "transient");
14812+
assert.equal(ghRetryKind(new Error("read: connection reset")), "transient");
1481614813

1481714814
const badGateway = Object.assign(new Error("gh: HTTP 502: Bad Gateway"), { stderr: "" });
1481814815
assert.equal(ghRetryKind(badGateway), "transient");
@@ -14830,6 +14827,9 @@ test("GitHub retry classifier distinguishes throttle and transient failures", ()
1483014827
{ stderr: "invalid character '<' looking for beginning of value\n" },
1483114828
);
1483214829
assert.equal(ghRetryKind(htmlInsteadOfJson), "transient");
14830+
assert.equal(ghRetryKind(new Error("dial tcp: connection refused")), "transient");
14831+
assert.equal(ghRetryKind(new Error("Could not resolve host: api.github.com")), "transient");
14832+
assert.equal(ghRetryKind(new Error("request timed out")), "transient");
1483314833

1483414834
const authFailure = Object.assign(new Error("gh: HTTP 401: Bad credentials"), {
1483514835
stderr: "Bad credentials",

0 commit comments

Comments
 (0)