Skip to content

[local-fix-worker-throttle-ci-watcher-credentia] fix(worker): throttle CI-watcher credential failures and make them actionable - #372

Open
danii1 wants to merge 2 commits into
mainfrom
feature/local-fix-worker-throttle-ci-watcher-credentia
Open

danii1 wants to merge 2 commits into
mainfrom
feature/local-fix-worker-throttle-ci-watcher-credentia

Conversation

@danii1

@danii1 danii1 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Why

Task local-fix-worker-throttle-ci-watcher-credentia: fix(worker): throttle CI-watcher credential failures and make them actionable

CI polling treated a permanent GitHub credential/permission failure as a per-PR, per-tick error. Two inaccessible PRs produced ~1,197 identical warnings because the watcher retried the same rejected token forever. The shared auth classifier also missed GitHub’s 403 permission-denial response, so CI and review polling could not recognize it as a non-transient credential problem.

What changed

  • Extended isGitHubAuthError in packages/code/src/lib/code-host/github/reviews.ts to treat non-rate-limit 403 responses as credential failures, including Resource not accessible by personal access token. 404, 5xx, and 403 rate-limit/abuse-detection responses remain non-auth errors. JSDoc now documents the 403 case and exclusions.
  • Added acquirer-level credential throttling in packages/code/src/lib/acquirers/ci-failure-watcher.ts:
    • At most one warning per rolling hour, using the existing injected now clock.
    • Warning names [poll:ci-failures] and the failing repo#number, includes the original error, names Actions: Read and Commit statuses: Read, says CI fixes resume once reads succeed and polling continues, and points to docs/code/worker.md.
    • A credential-clean successful tick logs one recovery line and rearms the throttle.
    • Mixed success/auth-failure ticks do not emit false recovery or rearm the throttle.
  • Kept transient failures on the existing per-PR, per-tick warning path.
  • Added operator guidance to docs/code/worker.md.
  • Added tests for 403 classification, 403 throttling, hourly re-warning, transient 403/500 behavior, recovery logging once and rearming, existing 404 watch retention, and ReviewPollingAcquirer’s new 403 throttling behavior.

Scope

Included: the shared auth classifier, CI watcher logging/throttle/recovery, relevant docs, and unit tests. Excluded: polling cadence, watch/unwatch behavior, green-PR backoff, retry budgets, escalation, fix invocation, dashboard/UI work, Sentry, Jira, mentions, other acquirers, new dependencies, and CLI surface changes. No commit, push, label, or PR was created.

Tradeoffs

The classifier treats any non-rate-limit 403 as a credential/permission failure rather than matching only specific message phrases. This avoids missing less common GitHub permission wording, at the cost of also throttling other permanent 403s. ReviewPollingAcquirer now throttles non-rate-limit 403s too; a test pins that behavior, which is consistent with the task’s desired outcome.

Blast Radius

isGitHubAuthError has only two consumers: CiFailureWatcherAcquirer and ReviewPollingAcquirer. The CI watcher now suppresses repeated credential warnings and logs recovery; review polling now also treats permission-denial 403s as auth failures and throttles them like 401s. Existing 404, 5xx, and rate-limit handling is unchanged.

Verification

  • bun run packages/code/tests/run-tests.ts ci-failure-watcher-acquirer.test.ts github-reviews-client.test.ts review-polling-acquirer.test.ts → 85 pass, 0 fail (256 assertions).
  • bun run --filter @getdevintern/code typecheck → exit 0.
  • bun run --filter @getdevintern/code format:check → all files formatted correctly.
  • bun run --filter @getdevintern/code lint → exit 0.
  • bun run lint → Turbo lint passed for all 12 packages, but the final root oxlint examples/pipeline-plugins step was blocked by the nono sandbox while resolving /agent-harness (Permission denied). This is an environment/sandbox limitation unrelated to the changed files; package-level lint passed.

This PR was automatically created by @devintern/code

…worker): throttle CI-watcher credential failures and make them actionable
- Branch GitHub credential guidance by failure class (401/invalid vs 403/missing permission) and drop the contradictory 'restart the worker' advice; share the helper between CI and review pollers.
- Key the CI watcher's credential warning throttle by repo so one repo's 403 cannot hide another repo's 401 in fleet mode, with per-repo recovery.
- Update review polling to use the shared guidance and document that credential failures intentionally skip captureError.
- Add tests for distinct per-repo failures, mixed success/failure ticks, and 403s originating from the Actions stream; assert review-poller 403 guidance.
- Update docs/code/worker.md to describe the shared throttle, guidance, and per-repo isolation.
- Iteration 2: persist escalatedSha only after the escalation comment lands, retry failed posts, and route their credential failures through the per-repo throttle/recovery path.
- Iteration 2: throttle review-polling credential warnings and 404-access notices per repo with a recovery line, and never treat a 404 as credential recovery.
- Iteration 2: classify only known credential-denial 403 bodies as auth failures so archived/locked/blocked/unknown 403s keep the normal capture/retry path; document the distinction.
- Iteration 2: add tests for escalation-post retry paths, per-repo review throttling, and non-credential 403 handling.
- Iteration 3: name the write permission (Issues/Pull requests: Write) for escalation-comment 403s and throttle CI read vs comment-post warnings independently, so a CI-read warning cannot suppress a write failure.
- Iteration 3: cap non-credential escalation-comment retries per head SHA and stop retrying after the cap instead of POSTing and warning on every poll.
- Iteration 3: track review-polling and CI recovery per failing endpoint stage so metadata-only success cannot clear a throttle that was never re-tested; add regression tests.
@danii1 danii1 added the devintern-authored Pull request written by DevIntern working its own backlog label Nov 4, 2026
@danii1

danii1 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Artifacts & verification

Run: 45 · Commit: 1bb95ce01c3306b5844edccc2cebe7246981a220

Change explanation

Produced by pr-explainer-narrative, execution 1 at 1bb95ce01c3306b5844edccc2cebe7246981a220.

Throttle CI-watcher credential failures and make them actionable

No PR template exists in this repository, so no template sections were required.

Why this change is needed

Per the task description: the worker's CI failure watcher polls CI for every open agent PR on each interval. When GitHub rejects the credential or permission, pollPr throws and tick() logs one warning per PR per tick, forever. The task reports 1,197 identical 403 warnings on the worker host (two per minute, indefinitely) for two PRs whose token lacks Actions: Read and Commit statuses: Read. The operator saw log spam with no actionable message, and CI auto-fix silently never ran for those PRs.

The task also notes existing precedent: ReviewPollingAcquirer already throttles rejected-credential warnings, but isGitHubAuthError only recognized 401 and missing credentials, not 403 permission denial.

I did not independently verify the host log counts or the token's permissions; that motivation is taken from the task.

What changed

Credential-failure classification. isGitHubAuthError now also treats a 403 as a credential failure when the message contains a known denial body (resource not accessible, bad credentials, must have admin rights). 404, 5xx, and 403 rate-limit/abuse responses stay false. A new classifyGitHubAuthError distinguishes invalid credentials from missing permission, and describeGitHubAuthFailure produces context-specific guidance: renew the token for 401, grant Actions: Read and Commit statuses: Read for CI reads, and Issues: Write or Pull requests: Write for comment posts.

CI watcher throttling and recovery. CiFailureWatcherAcquirer keeps per-repo, per-operation throttle state (CI poll vs escalation comment), warns at most once per rolling hour using the existing injected now, and logs one recovery line when the same operation succeeds again. Recovery is only claimed when the CI streams were actually fetched (ciQueried), so a metadata-only PR read or a skipped fork-PR poll cannot clear a CI throttle. Non-credential failures keep the previous one-warning-per-PR-per-tick behavior.

Escalation-comment handling (beyond the literal task). Escalation state (escalatedSha) is now persisted only after the comment lands. Credential failures while posting keep retrying with a throttled warning; non-credential post failures are retried three times, then the escalation is frozen until the head SHA changes.

Review polling (beyond the literal task). The single instance-wide auth-warning throttle is replaced with per-repo, per-stage state (pr-state, reviews, comments, write), with stage-aware recovery logging and a separate per-repo 404 warning throttle.

Docs and tests. docs/code/worker.md documents the throttled, actionable warning behavior and which 403s are not credential failures. Tests cover classification, throttling, clock rollover, recovery, rearm, per-repo isolation, metadata-only success, transient errors, and escalation retry/cap behavior.

Validation actually performed

Recorded in the pipeline artifacts, not re-run for this write-up:

  • __verify-1 records running the three required test files (ci-failure-watcher-acquirer.test.ts, github-reviews-client.test.ts, review-polling-acquirer.test.ts) with 29 + 9 + 38 passing tests, bun run --filter @getdevintern/code typecheck exiting 0, and bun run lint completing 12/12 tasks successfully.
  • auto-review reports 136 passing targeted tests plus a clean typecheck; an earlier review iteration reports 84 targeted tests and clean oxlint.
  • The artifacts are inconsistent on aggregate test counts, and the provided evidence contains no raw command output. I did not run any commands to validate the behavior myself.

No automated validation was performed as part of writing this explanation.

Material risks

  • Scope exceeds the stated task. The task said not to change escalation behavior or other acquirers, and to make no change to fix invocation. The diff changes escalation retry/freeze semantics and restructures review-polling throttling. These may be justified hardening, but a reviewer should confirm the expanded scope is intended.
  • Pattern-based 403 classification. Only three denial bodies are recognized. Other permission wording (for example SSO or plain Forbidden) will not be throttled, so the original log flood can still occur for those. Conversely, a non-auth 403 whose text happens to contain one of the patterns would be treated as a credential failure and skipped by Sentry capture in the pollers.
  • Duplicate escalation comment. Because escalation is now persisted after the post, a crash between a successful post and the state write can re-post the comment. Previously the opposite failure mode existed (state set before post, so a failed post was lost).
  • Frozen escalation without a comment. After three non-credential post failures, the watcher stops trying until the head changes and only leaves a warning, so a human may never be notified.
  • Throttle granularity. Warnings are per repo and per operation, not per PR. A second distinct PR in the same repo can be hidden behind another PR's warning for up to an hour. This is intentional to stop flooding but changes what an operator sees.
  • Recovery accuracy depends on ciQueried. reconcile() passes a throwaway progress object, so it neither announces recovery nor rearms. A successful metadata-only poll deliberately does not clear a throttle, which can delay the recovery line but avoids false positives.
  • In-memory throttle state. It resets on worker restart, which means the first failure after a restart warns immediately. That matches the "first occurrence logs immediately" design.

Where reviewers should focus

  1. packages/code/src/lib/code-host/github/reviews.ts - classification patterns and guidance text. Everything else depends on these being right.
  2. packages/code/src/lib/acquirers/ci-failure-watcher.ts - throttle/recovery keying, the ciQueried gate, and the changed escalatedSha state transition in both escalation paths.
  3. packages/code/src/lib/acquirers/review-polling.ts - the per-repo/per-stage refactor, especially the recovery rule that keeps a stage throttled when any PR in the repo failed that stage. A review artifact notes the adjacent comment ("one PR's failure must not mask another's success") contradicts the implemented behavior; confirm the intended contract and comment.
  4. The three modified test files - check that the tests actually assert the acceptance criteria (one warning per hour, rearm on recovery, 429/403 rate-limit and 500 unchanged, 404 unchanged, metadata-only success not clearing the throttle).

Observed evidence vs inference

Observed from the diff and repository: the code paths, patterns, tests, and documentation described above; the repository has no PR template; the worker.md anchor used in the warning resolves to ## CI failures on the agent's PRs.

Taken from pipeline artifacts, not independently confirmed: the test counts, typecheck result, and lint result. Inferred, not observed: the risks about restart behavior, crash-window duplication, and delayed recovery are reasoning from the implementation, not reproduced or tested in the provided evidence.

Visual explanation

Produced by pr-explainer-visual, execution 1 at 1bb95ce01c3306b5844edccc2cebe7246981a220.

Show artifact

Summary

The CI failure watcher used to log one identical warning per PR per tick whenever GitHub rejected its credential, so a token missing CI read scopes flooded the worker log indefinitely without telling the operator what to fix. This change classifies credential-scoped 403 responses as credential failures, throttles those warnings to once per repo per operation per hour with the exact missing permissions named, and logs a single recovery line when the same endpoint succeeds again. Transient failures (404, 5xx, rate limits, non-credential 403s) keep their existing per-tick reporting. It matters because CI auto-fix currently never runs for those PRs while the operator only sees noise.

Evidence

Routing of one failed PR poll, as implemented:

pollPr() throws
   |
   +-- isGitHubAuthError(error)
   |      |-- 401 / missing token            -> invalid-credential
   |      |-- 403 + "resource not accessible" -> missing-permission
   |      |      ("bad credentials", "must have admin rights" also match)
   |      |-- 403 rate limit / abuse / archived / locked / plain Forbidden -> false
   |      +-- 404, 5xx                        -> false
   |
   +-- true  -> warnAuthFailure(repo, "poll" | "comment")
   |              first occurrence: warn immediately
   |              same repo+operation within 1h: silent
   |              after 1h: warn again
   |              on later success of that same operation: one recovery line + rearm
   |
   +-- false -> existing per-PR, per-tick warning (unchanged)

Scope of the new classification, gathered from the merged diff:

Input Credential failure? Reporting
401, no GITHUB_TOKEN yes throttled, hourly
403 Resource not accessible... yes throttled, hourly
403 rate limit / abuse detection no per PR per tick
403 archived / locked / blocked / plain Forbidden no per PR per tick
404 Not Found no per PR per tick (watch retention unchanged)
5xx no per PR per tick

Recovery is gated on the endpoint actually being re-read: the CI watcher only claims recovery when ciQueried is true, so a fork PR or a missing head SHA (metadata-only read) cannot clear a CI throttle; review polling tracks pr-state / reviews / comments / write separately.

Validation, taken from the pipeline artifacts and not re-run while writing this:

  • __verify-1 records the three required suites passing (29 + 9 + 38 tests), bun run --filter @getdevintern/code typecheck exiting 0, and bun run lint completing 12/12 tasks.
  • auto-review records 136 targeted tests passing plus a clean typecheck; an earlier iteration reports 84 targeted tests and clean oxlint.
  • The artifacts disagree on aggregate test counts and contain no raw command output. Before/after execution results were not captured, so this explanation shows no measured before/after behavior. No commands were run to validate the change for this write-up.

Merge Danger

Blast radius is narrow but not zero. isGitHubAuthError has two consumers, review-polling.ts and ci-failure-watcher.ts (per the verification artifact's grep), and in both it only gates warning and error-capture decisions, not which PRs are polled or whether a fix is invoked. Reverting restores the previous behavior exactly: hourly log spam returns and CI auto-fix still does not run for under-scoped tokens, so nothing functionally breaks, but the operator loses the actionable message. The nontrivial part is the escalation path: [redacted] is now persisted only after the give-up comment lands, with credential failures retrying indefinitely (throttled) and other post failures capped at three attempts before freezing until the head SHA changes. That is a behavioral change beyond log classification, and a revert would reintroduce the old "freeze even if the comment never posted" failure mode; a crash between a successful post and the state write can now re-post the comment. Classification is pattern-based, so permission wording outside the three recognized bodies will not be throttled and the original flood can recur for those, while a non-auth 403 containing a matched phrase would be treated as a credential failure and skipped by Sentry capture. The throttle is in-memory and resets on restart (first failure warns immediately). Given that the changed decisions are logging and comment retry, and polling/fix invocation is untouched, this is reasonable to ship without a staged rollout, provided the escalation semantics change is accepted as intended scope.

Verification report

Produced by __verify-1, execution 1 at 608cd01f8cc696286513f4fc2d7244137f3cfa71.

Show artifact
{
  "summary": "All functional requirements are implemented: isGitHubAuthError now classifies non-rate-limit 403s as credential failures while leaving 404/5xx/rate-limit errors alone; CiFailureWatcherAcquirer throttles credential warnings per acquirer instance using the injected clock, emits an actionable single-line warning, logs one recovery line, and rearms the throttle; transient errors retain per-PR/per-tick warnings; docs and tests were updated. Verified by running the three required test files (29 + 9 + 38 passing), `bun run --filter @getdevintern/code typecheck` (exit 0), and `bun run lint` (12/12 tasks successful). Blast-radius check: `isGitHubAuthError` has only two consumers (`review-polling.ts:720` and the new `ci-failure-watcher.ts:393`); ReviewPollingAcquirer already routes auth errors to a throttled warning and continue, and its suite passes, so broadening classification changes warning/Sentry routing only, not polling or fix invocation.",
  "items": [
    {
      "priority": "low",
      "confidence": "medium",
      "category": "bug",
      "file": "packages/code/src/lib/code-host/github/reviews.ts",
      "line": "156",
      "issue": "The new 403 branch classifies every 403 except rate-limit/abuse-detection messages as a credential failure. This is broader than the stated example ('Resource not accessible...') and could throttle/report a non-permission 403 (for example a repository-access-blocked response) as an auth outage. It still satisfies the explicit exclusion list (404/5xx/rate-limit), so this is a precision caveat rather than an unmet requirement.",
      "evidence": "packages/code/src/lib/code-host/github/reviews.ts:156-161 returns true for any message containing 'GitHub API error (403)' unless lowercased text contains 'rate limit' or 'abuse detection'.",
      "suggestion": "If stricter behavior is desired, additionally require permission-denial markers such as 'resource not accessible', 'forbidden', or 'must have' before returning true; otherwise document that all non-rate-limit 403s are intentionally treated as permanent credential failures."
    },
    {
      "priority": "low",
      "confidence": "medium",
      "category": "bug",
      "file": "packages/code/src/lib/acquirers/ci-failure-watcher.ts",
      "line": "427",
      "issue": "The throttled warning says the token 'is missing CI read permission; grant Actions: Read and Commit statuses: Read' for every auth-classified error, including HTTP 401 (expired/revoked credential). For a 401 this replaces the previous 401-specific 'expired or revoked' guidance with a potentially misleading missing-permission message.",
      "evidence": "packages/code/src/lib/code-host/github/reviews.ts:150-154 still treats 'GitHub API error (401)' as auth, and packages/code/src/lib/acquirers/ci-failure-watcher.ts:426-431 emits the permission wording unconditionally.",
      "suggestion": "Branch the remediation text on 401 versus 403 so an expired/revoked token is told to renew GITHUB_TOKEN, while a 403 permission denial names Actions: Read and Commit statuses: Read."
    },
    {
      "priority": "info",
      "confidence": "medium",
      "category": "bug",
      "file": "packages/code/src/lib/acquirers/ci-failure-watcher.ts",
      "line": "449",
      "issue": "The separate reconcile() path polls a PR without the new catch/throttle/recovery handling. A success there does not log the recovery line or reset the throttle, and an auth failure propagates instead of warning. In practice this path is only invoked by the GitLab relay integration, which does not produce GitHub auth errors, so the GitHub CI watcher is unaffected.",
      "evidence": "packages/code/src/lib/acquirers/ci-failure-watcher.ts:449-458 calls pollPr directly with no isGitHubAuthError handling; grep shows the only production caller is packages/code/src/lib/workspace/gitlab-fleet-events.ts:277.",
      "suggestion": "If reconcile() ever becomes reachable for GitHub, route its errors through warnAuthFailure and note recovery on success; otherwise no action is needed for the current GitHub path."
    }
  ],
  "approved": true
}

Further artifacts omitted because they exceed the comment preview budget.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devintern-authored Pull request written by DevIntern working its own backlog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant