Warded agents aren't meaningfully conservative about github API rate limits #466

Closed
opened 2026-07-01 22:33:08 +00:00 by coilysiren · 4 comments
Owner

They probably aren't wildly noisy but given how github is these days, a "is this conservative enough" pass on every angle is warranted

They probably aren't wildly noisy but given how github is these days, a "is this conservative enough" pass on every angle is warranted
Member

Labeled headless/P3: scope is a conservativeness audit of GitHub API usage (rate-limit headers respected, backoff, caching, batch reads) plus fixes. Rises in priority with #489 (warded on GitHub issues). Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.

Labeled headless/P3: scope is a conservativeness audit of GitHub API usage (rate-limit headers respected, backoff, caching, batch reads) plus fixes. Rises in priority with #489 (warded on GitHub issues). Recorded by Claude Code (Fable) during the 2026-07-01 ward launch triage session with Kai.
Member

🔎 ward agent advisor

ward agent advisor --driver claude ran a one-shot standard research pass on this question:

Audit how conservative warded agents are about GitHub API rate limits (ward#466) and post ranked findings as a comment. Read-only. Inventory every place ward/warded touches the GitHub API - the gh CLI calls in the github forge lane (ward#489, docs/agent-github.md), any mirror sync, PR/issue reads and writes - and for each assess rate-limit exposure: unpaginated list loops, presence/absence of retry+backoff, polling frequency, secondary-rate-limit risk from rapid writes. Rank findings by risk, each with a concrete mitigation (backoff, conditional requests/ETags, pagination caps, honoring Retry-After / X-RateLimit headers). Ground every claim in file:line. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor).


ward#466 - GitHub API rate-limit conservativeness audit (read-only, plan to verify)

The agent inventoried every place ward and warded runs touch the GitHub API. Two truths shape the whole picture, and both are good news:

  • The only polling loop in ward never touches GitHub. backlogPoll builds a Forgejo client unconditionally (cmd/ward/agent_director.go:975, r.hostForgejoClient(ctx)), and the director heartbeat reconciles through it. The GitHub lane (ward#489) has no reconciler, no watch loop, and no repo-wide issue list. It only ever addresses a single issue by explicit number.
  • The GitHub Go lane is auth-required, so the 60/hr unauthenticated cliff is unreachable. hostGitHubClient errors if gh is off PATH and the token resolves inside gh from the environment (cmd/ward/github_ops.go:27-32, token source selected by WARD_GITHUB_TOKEN_SOURCE, commit 993e222). Every call runs against the 5000/hr authenticated budget.

So the primary-rate-limit exposure is structurally small. The real GitHub risk surface is secondary rate limits (content-creation abuse detection) and the total absence of retry, backoff, or Retry-After honoring on every path. Findings ranked by risk below. Every line reference is against main at commit 993e222.

Where ward and warded touch the GitHub API

  • Go lane (cmd/ward/github_ops.go) - gh-backed issue client behind issueForge: getIssue (:44), listIssueComments (:80), createIssue (:112), commentIssue (:131), closeIssue (:145), reopenIssue (:153). Reads happen at pre-flight (cmd/ward/agent.go:526 and :536). Writes happen when the run posts its WARD-OUTCOME and when closes #N fires (cmd/ward/agent_route.go:145). Note the salvage-reaper path (cmd/ward/container_reap.go:414-427) is Forgejo-only, its salvageNotifier is satisfied by *forgejoClient, so it adds no GitHub load.
  • CI - mirror (.forgejo/workflows/mirror-to-github.yml) - runs on every push to main: force-push refs, then PATCH /repos description and PUT .../topics, then a paginated GET /releases scrub loop with per-tag GET + DELETE (:75-120).
  • CI - release (.forgejo/workflows/release.yml) - the GitHub publish step: POST /releases, GET /releases/tags, GET .../assets?per_page=100, per-asset DELETE + upload (:188-251).

Findings, ranked by risk

1. (Medium-high) No retry, backoff, or Retry-After honoring on any GitHub call, Go or CI. Every gh invocation runs through shell.Runner.Capture, which is a plain exec.CommandContext with no retry (cli-guard cli/shell/shell.go:130, reached via cmd/ward/github_ops.go:35-37). Every CI call uses curl -fsSL with no --retry and no Retry-After read (mirror-to-github.yml:75-118, release.yml:144-251). A 403 secondary-rate-limit or a transient 5xx therefore fails the operation hard instead of waiting the interval GitHub hands back in Retry-After. This is the systemic gap. Mitigation: honor Retry-After and X-RateLimit-Reset on 403/429, add bounded exponential backoff with jitter around the gh shell-out, and pass --retry/--retry-delay on every CI curl.

2. (Medium) Mirror and release workflows both fire on every push to main, and a minor release is cut on every push, so content-creation writes cluster with no throttle. GitHub's abuse detection targets exactly this shape: create-release + N asset uploads + PATCH/PUT metadata, bunched when several merges land close together. With no backoff (finding 1) a burst trips the secondary limit and reds the runs, and per house rules a red mirror run is the ward#237/ward#477 silent-freeze failure mode in reverse. Mitigation: honor Retry-After in the publish and metadata steps, add jitter between asset uploads, and consider debouncing metadata sync (it is idempotent, so it need not run on every push).

3. (Low-medium) The pre-flight reads one issue with two separate gh issue view invocations. fetchIssue (cmd/ward/agent.go:526 -> github_ops.go:44) and fetchIssueComments (agent.go:536 -> github_ops.go:80) each spawn a fresh client and a fresh gh process against the same issue, one for number,title,body,state,url and one for comments. gh issue view --json number,title,body,state,url,comments returns all of it in a single call. This doubles the per-dispatch read cost and the process count for zero benefit. Mitigation: fetch both field sets in one gh issue view and split the decode.

4. (Low) gh issue view --json comments pulls the entire comment thread with no cap (github_ops.go:80-93). gh paginates internally, so a pathologically long thread pulls many GraphQL pages per pre-flight. Real threads are short and gh handles the paging, so exposure is small, but the read is unbounded on ward's side. Mitigation: none strictly required. If ward ever moves off gh to raw REST, cap the comment pages and read newest-first.

5. (Low) No conditional requests or ETag caching on repeated reads. Every pre-flight re-reads the issue cold even when a prior dispatch of the same issue read it moments ago. gh keeps no ETag cache, so retries and re-dispatches of one issue re-spend quota. Volume is low because each dispatch reads once. Mitigation: if a future refactor batches or retries dispatches, add an in-run cache keyed by issue ref, or move to REST with If-None-Match.

Balance note

The agent found no unpaginated repo-wide list loop on the GitHub side, no anonymous low-limit path, and no GitHub polling. The CI GET /releases scrub does paginate correctly with per_page=100 and a page loop that breaks on empty (mirror-to-github.yml:94-100). So ward is already conservative on the two failure modes that usually bite (list-everything loops and the 60/hr anonymous cliff). The remaining work is defensive: teach every GitHub call to back off and honor Retry-After, and stop clustering content-creation writes.

Status

This is a plan to verify, not a verified result. The agent read the sources listed above but ran nothing against the live GitHub API and did not measure a real call budget under load. Before acting, confirm gh's own default behavior on 403 secondary-rate-limit for issue view/issue create/issue comment (some gh versions wait internally, which would soften finding 1 for the Go lane while leaving the CI curls exposed), and confirm the release cadence assumption that a minor version is cut on every push to main.


Researched and posted automatically by ward agent advisor --driver claude (ward#179). This is one-shot research, not a carried change - verify before acting on it.

— Claude (she/her), via ward agent

### 🔎 ward agent advisor `ward agent advisor --driver claude` ran a one-shot **standard** research pass on this question: > Audit how conservative warded agents are about GitHub API rate limits (ward#466) and post ranked findings as a comment. Read-only. Inventory every place ward/warded touches the GitHub API - the gh CLI calls in the github forge lane (ward#489, docs/agent-github.md), any mirror sync, PR/issue reads and writes - and for each assess rate-limit exposure: unpaginated list loops, presence/absence of retry+backoff, polling frequency, secondary-rate-limit risk from rapid writes. Rank findings by risk, each with a concrete mitigation (backoff, conditional requests/ETags, pagination caps, honoring Retry-After / X-RateLimit headers). Ground every claim in file:line. End noting it is a plan to verify. Repo voice rules (no em-dashes, no semicolons in prose, name the actor). --- ## ward#466 - GitHub API rate-limit conservativeness audit (read-only, plan to verify) The agent inventoried every place ward and warded runs touch the GitHub API. Two truths shape the whole picture, and both are good news: - **The only polling loop in ward never touches GitHub.** `backlogPoll` builds a Forgejo client unconditionally (`cmd/ward/agent_director.go:975`, `r.hostForgejoClient(ctx)`), and the director heartbeat reconciles through it. The GitHub lane (ward#489) has no reconciler, no watch loop, and no repo-wide issue list. It only ever addresses a single issue by explicit number. - **The GitHub Go lane is auth-required, so the 60/hr unauthenticated cliff is unreachable.** `hostGitHubClient` errors if `gh` is off PATH and the token resolves inside `gh` from the environment (`cmd/ward/github_ops.go:27-32`, token source selected by `WARD_GITHUB_TOKEN_SOURCE`, commit 993e222). Every call runs against the 5000/hr authenticated budget. So the primary-rate-limit exposure is structurally small. The real GitHub risk surface is **secondary rate limits** (content-creation abuse detection) and **the total absence of retry, backoff, or `Retry-After` honoring** on every path. Findings ranked by risk below. Every line reference is against `main` at commit 993e222. ### Where ward and warded touch the GitHub API - **Go lane (`cmd/ward/github_ops.go`)** - `gh`-backed issue client behind `issueForge`: `getIssue` (`:44`), `listIssueComments` (`:80`), `createIssue` (`:112`), `commentIssue` (`:131`), `closeIssue` (`:145`), `reopenIssue` (`:153`). Reads happen at pre-flight (`cmd/ward/agent.go:526` and `:536`). Writes happen when the run posts its WARD-OUTCOME and when `closes #N` fires (`cmd/ward/agent_route.go:145`). Note the salvage-reaper path (`cmd/ward/container_reap.go:414-427`) is Forgejo-only, its `salvageNotifier` is satisfied by `*forgejoClient`, so it adds no GitHub load. - **CI - mirror (`.forgejo/workflows/mirror-to-github.yml`)** - runs on every push to `main`: force-push refs, then `PATCH /repos` description and `PUT .../topics`, then a paginated `GET /releases` scrub loop with per-tag `GET` + `DELETE` (`:75-120`). - **CI - release (`.forgejo/workflows/release.yml`)** - the GitHub publish step: `POST /releases`, `GET /releases/tags`, `GET .../assets?per_page=100`, per-asset `DELETE` + upload (`:188-251`). ### Findings, ranked by risk **1. (Medium-high) No retry, backoff, or `Retry-After` honoring on any GitHub call, Go or CI.** Every `gh` invocation runs through `shell.Runner.Capture`, which is a plain `exec.CommandContext` with no retry (cli-guard `cli/shell/shell.go:130`, reached via `cmd/ward/github_ops.go:35-37`). Every CI call uses `curl -fsSL` with no `--retry` and no `Retry-After` read (`mirror-to-github.yml:75-118`, `release.yml:144-251`). A 403 secondary-rate-limit or a transient 5xx therefore fails the operation hard instead of waiting the interval GitHub hands back in `Retry-After`. This is the systemic gap. Mitigation: honor `Retry-After` and `X-RateLimit-Reset` on 403/429, add bounded exponential backoff with jitter around the `gh` shell-out, and pass `--retry`/`--retry-delay` on every CI curl. **2. (Medium) Mirror and release workflows both fire on every push to `main`, and a minor release is cut on every push, so content-creation writes cluster with no throttle.** GitHub's abuse detection targets exactly this shape: create-release + N asset uploads + `PATCH`/`PUT` metadata, bunched when several merges land close together. With no backoff (finding 1) a burst trips the secondary limit and reds the runs, and per house rules a red mirror run is the ward#237/ward#477 silent-freeze failure mode in reverse. Mitigation: honor `Retry-After` in the publish and metadata steps, add jitter between asset uploads, and consider debouncing metadata sync (it is idempotent, so it need not run on every push). **3. (Low-medium) The pre-flight reads one issue with two separate `gh issue view` invocations.** `fetchIssue` (`cmd/ward/agent.go:526` -> `github_ops.go:44`) and `fetchIssueComments` (`agent.go:536` -> `github_ops.go:80`) each spawn a fresh client and a fresh `gh` process against the same issue, one for `number,title,body,state,url` and one for `comments`. `gh issue view --json number,title,body,state,url,comments` returns all of it in a single call. This doubles the per-dispatch read cost and the process count for zero benefit. Mitigation: fetch both field sets in one `gh issue view` and split the decode. **4. (Low) `gh issue view --json comments` pulls the entire comment thread with no cap** (`github_ops.go:80-93`). `gh` paginates internally, so a pathologically long thread pulls many GraphQL pages per pre-flight. Real threads are short and `gh` handles the paging, so exposure is small, but the read is unbounded on ward's side. Mitigation: none strictly required. If ward ever moves off `gh` to raw REST, cap the comment pages and read newest-first. **5. (Low) No conditional requests or ETag caching on repeated reads.** Every pre-flight re-reads the issue cold even when a prior dispatch of the same issue read it moments ago. `gh` keeps no ETag cache, so retries and re-dispatches of one issue re-spend quota. Volume is low because each dispatch reads once. Mitigation: if a future refactor batches or retries dispatches, add an in-run cache keyed by issue ref, or move to REST with `If-None-Match`. ### Balance note The agent found no unpaginated repo-wide list loop on the GitHub side, no anonymous low-limit path, and no GitHub polling. The CI `GET /releases` scrub does paginate correctly with `per_page=100` and a page loop that breaks on empty (`mirror-to-github.yml:94-100`). So ward is already conservative on the two failure modes that usually bite (list-everything loops and the 60/hr anonymous cliff). The remaining work is defensive: teach every GitHub call to back off and honor `Retry-After`, and stop clustering content-creation writes. ### Status This is a plan to verify, not a verified result. The agent read the sources listed above but ran nothing against the live GitHub API and did not measure a real call budget under load. Before acting, confirm `gh`'s own default behavior on 403 secondary-rate-limit for `issue view`/`issue create`/`issue comment` (some `gh` versions wait internally, which would soften finding 1 for the Go lane while leaving the CI curls exposed), and confirm the release cadence assumption that a minor version is cut on every push to `main`. --- Researched and posted automatically by `ward agent advisor --driver claude` (ward#179). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-466 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-02T17:40:10Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-466` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-02T17:40:10Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - ward's own GitHub client now reads issues/comments and flips state via gh api (REST), off the tighter GraphQL budget.

Honest retro: the interesting part of this issue was that ward already had the doctrine. Its PreToolUse hook nags agents to prefer gh api /repos/... over gh issue view to dodge the GraphQL rate-limit budget, but ward's own github_ops.go client was doing exactly the thing it warns against. So the fix wrote itself once I saw the mismatch: move getIssue, listIssueComments, and the close/reopen state flips onto REST, and leave create/comment where they were (already REST POSTs that need --body-file for the signed body).

What fought back was entirely the pre-commit suite, not the Go. The code-comments hook caps contiguous comment blocks at 2 lines and 90 chars, and the doc-size hook caps a markdown file at 80 lines / 4000 chars - my first pass blew all three, so the rationale got split into its own docs/github-rate-limits.md and the inline comments got whittled to pointers. Landed within 5 chars of the doc cap, which is its own small comedy.

Confidence is high on the reads and the PATCH flips: same argv-only exec path, tests green, and the REST field-shape (html_url, created_at, user) is mapped explicitly. The one thing I could not exercise end-to-end from here is a live GitHub run, so my assumption that gh api --paginate merges the comment pages into a single JSON array rests on gh's documented behavior rather than a run I watched. Per-run GitHub call volume is small (a handful of reads plus reservation/outcome/close), so I deliberately did not add rate-limit backoff beyond the existing reservation-post retry - that felt like solving a load that isn't there. If anything, a reasonable follow-up would be a tiny fake-runner test asserting the exact gh api argv for each verb, which I skipped since nothing in the repo currently fakes that Runner.

WARD-OUTCOME: done - ward's own GitHub client now reads issues/comments and flips state via `gh api` (REST), off the tighter GraphQL budget. Honest retro: the interesting part of this issue was that ward already *had* the doctrine. Its PreToolUse hook nags agents to prefer `gh api /repos/...` over `gh issue view` to dodge the GraphQL rate-limit budget, but ward's own `github_ops.go` client was doing exactly the thing it warns against. So the fix wrote itself once I saw the mismatch: move getIssue, listIssueComments, and the close/reopen state flips onto REST, and leave create/comment where they were (already REST POSTs that need --body-file for the signed body). What fought back was entirely the pre-commit suite, not the Go. The code-comments hook caps contiguous comment blocks at 2 lines and 90 chars, and the doc-size hook caps a markdown file at 80 lines / 4000 chars - my first pass blew all three, so the rationale got split into its own docs/github-rate-limits.md and the inline comments got whittled to pointers. Landed within 5 chars of the doc cap, which is its own small comedy. Confidence is high on the reads and the PATCH flips: same argv-only exec path, tests green, and the REST field-shape (html_url, created_at, user) is mapped explicitly. The one thing I could not exercise end-to-end from here is a live GitHub run, so my assumption that `gh api --paginate` merges the comment pages into a single JSON array rests on gh's documented behavior rather than a run I watched. Per-run GitHub call volume is small (a handful of reads plus reservation/outcome/close), so I deliberately did not add rate-limit backoff beyond the existing reservation-post retry - that felt like solving a load that isn't there. If anything, a reasonable follow-up would be a tiny fake-runner test asserting the exact `gh api` argv for each verb, which I skipped since nothing in the repo currently fakes that Runner.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#466
No description provided.