Skip to content

fix(pr-agent): classify timeouts per ATTEMPT, not on total job time - #40

Open
yakimoto wants to merge 1 commit into
mainfrom
fix/pr-agent-per-attempt-timeout
Open

fix(pr-agent): classify timeouts per ATTEMPT, not on total job time#40
yakimoto wants to merge 1 commit into
mainfrom
fix/pr-agent-per-attempt-timeout

Conversation

@yakimoto

@yakimoto yakimoto commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Re-syncs this repo to wave-foundation-public#72, which landed after the inline pr-agent lane was adopted here. Tracked as wave-pen#417.

The defect

The adopted template stamped AGENT_START once, before attempt 1, then compared total job time — attempt 1 + the 45s backoff + attempt 2 — against STEP_BUDGET_S=360, a budget its own comment calls per-attempt.

Two healthy-but-slow attempts (~180s each, ~405s together) therefore reported:

pr-agent TIMED OUT … A hang, NOT a rate limit.

…sending the next reader to debug a hang that never happened. The else-branch lied the other way, asserting the run was "well inside the budget" from the same misused total.

Found by qodo review on wave-monitor#48 and confirmed against the file before acting.

The fix

Stamp each attempt separately and classify on the longest attempt, with if: always() end stamps so an attempt killed by its step timeout still records one — exactly the case the classifier exists to catch. Total wall time is still reported as context but no longer decides the verdict.

case now before
180s + 180s (405s total) failed after 2 attempts TIMED OUT
attempt killed at ~358s TIMED OUT

Not urgent, not ignorable

The defect is in a message, not behaviour — the lane still retries, still renders NEUTRAL, still never blocks a PR. But that verdict step exists precisely because "a confidently wrong cause is worse than no cause", so a classifier that can misname a hang defeats its own purpose.

Job id pr_agent and every on: trigger unchanged — the job id is the check-run context and branch protection matches on it.

Refs wave-av/wave-pen#417, wave-av/wave-pen#388


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Note

Low Risk
CI classifier messaging only; retry, NEUTRAL rendering, and job identity are unchanged, so PRs are not gated differently.

Overview
Fixes a misclassification in the advisory pr-agent workflow: two slow-but-healthy attempts plus backoff (~405s) were compared against the per-attempt 360s step budget and reported as a hang.

Each attempt now gets its own start/end stamps (if: always() so a timeout-killed step still records an end). The verdict uses the longest attempt, with 15s slack for runner kill lag. Wall time is still logged but no longer decides hang vs 429. Retry behavior and NEUTRAL (non-blocking) outcomes are unchanged.

Reviewed by Cursor Bugbot for commit f12afa1. Bugbot is set up for automated code reviews on this repo. Configure here.

Review in cubic

Summary by Sourcery

Classify pr-agent failures using per-attempt execution time so slow retries are not incorrectly reported as timeouts while retaining non-blocking retry behavior.

Bug Fixes:

  • Correct timeout classification in the advisory pr-agent workflow by evaluating the longest individual attempt instead of total job duration.

Enhancements:

  • Preserve timeout evidence for attempts terminated by step limits and include per-attempt and total wall-clock timing in diagnostic messages.

Re-syncs this repo to wave-foundation-public#72, which landed after the inline
lane was adopted here.

THE DEFECT. The adopted template stamped AGENT_START once, before attempt 1,
then compared TOTAL job time — attempt 1 + the 45s backoff + attempt 2 —
against STEP_BUDGET_S=360, a budget its own comment calls PER-ATTEMPT. Two
healthy-but-slow attempts (~180s each, ~405s together) therefore reported

  "pr-agent TIMED OUT ... A hang, NOT a rate limit."

sending the next reader to debug a hang that never happened; the else-branch
lied the other way, asserting the run was "well inside the budget" from the
same misused total.

Found by qodo review on wave-monitor#48 and confirmed against the file before
acting.

THE FIX. Stamp each attempt separately and classify on the LONGEST attempt,
with if: always() end stamps so an attempt killed BY its step timeout still
records one — exactly the case the classifier exists to catch. Total wall time
is still reported as context but no longer decides the verdict.

NOT URGENT, NOT IGNORABLE. The defect is in a MESSAGE, not in behaviour: the
lane still retries, still renders NEUTRAL, still never blocks a PR. But that
verdict step exists precisely because "a confidently wrong cause is worse than
no cause", so shipping a classifier that can misname a hang defeats its purpose.

Job id pr_agent and every on: trigger unchanged — the job id is the check-run
context and branch protection matches on it.

Refs wave-av/wave-pen#417, wave-av/wave-pen#388
@codeant-ai

codeant-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Reviewed your PR f12afa1 Aug 24, 2026 · 13:38 13:38

@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_b4197ca9-82e5-4ffe-9e09-8e22e62020e7)

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 28 minutes.

View limit details

Limit details: You’ve used the included review currently available. Your 91 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cfb1761f-5ba4-4f71-bd98-81a1793a4c70

📥 Commits

Reviewing files that changed from the base of the PR and between 3b94d14 and f12afa1.

📒 Files selected for processing (1)
  • .github/workflows/pr-agent.yml

Comment @coderabbitai help to get the list of available commands.

@codeant-ai codeant-ai Bot added the size:M This PR changes 30-99 lines, ignoring generated files label Aug 24, 2026
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix pr-agent timeout classification to use per-attempt duration

🐞 Bug fix ⚙️ Configuration changes 🕐 10-20 Minutes

Grey Divider

AI Description

• Stamp start/end timestamps for each pr-agent retry attempt.
• Classify timeouts using the longest single attempt, not total job wall time.
• Keep total wall time as context while preventing misleading “hang” messaging.
Diagram

graph TD
  A["GitHub Actions: pr_agent job"] --> B["Stamp attempt 1 start"] --> C["Run PR-Agent attempt 1"] --> D{"Attempt 1 failed?"}
  D -- "yes" --> E["Stamp attempt 1 end"] --> F["Backoff 45s"] --> G["Stamp attempt 2 start"] --> H["Run PR-Agent attempt 2"] --> I["Stamp attempt 2 end"] --> J["Verdict: classify by longest attempt"]
  D -- "no" --> J
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use Bash SECONDS within each attempt
  • ➕ No need to write start/end stamps into GITHUB_ENV
  • ➕ Simpler arithmetic (elapsed is directly available within a single step)
  • ➖ Doesn’t help when an attempt is killed mid-step (you still lose the end timestamp)
  • ➖ Harder to aggregate across multiple steps/attempt boundaries without persisted env
2. Query step timing via GitHub API for the run
  • ➕ Uses authoritative step start/end timestamps from GitHub
  • ➕ Avoids reliance on runner clock/arithmetic and missing env vars
  • ➖ Requires additional permissions/token usage and API calls
  • ➖ More complex and brittle; overkill for an advisory diagnostic message

Recommendation: Current approach is appropriate: persisting per-attempt start/end stamps in GITHUB_ENV and using if: always() ensures timeouts still produce an end stamp, which is exactly the failure mode being classified. The added defaults/guards (zeroing negatives, slack) also reduce the chance the classifier itself fails or mislabels borderline timeouts.

Files changed (1) +44 / -6

Bug fix (1) +44 / -6
pr-agent.ymlRecord per-attempt timings and fix timeout verdict logic +44/-6

Record per-attempt timings and fix timeout verdict logic

• Replaces a single AGENT_START timestamp with per-attempt start/end stamps for attempt 1 and attempt 2, using if: always() so step-timeouts still record end times. Updates the verdict step to compute attempt durations, classify timeouts based on the longest attempt (with a small slack), and keep total wall time as informational context only.

.github/workflows/pr-agent.yml

@macroscopeapp

macroscopeapp Bot commented Aug 24, 2026

Copy link
Copy Markdown

Approvability

Verdict: Would Approve

Macroscope's review found this PR approvable — This is a self-contained, one-file CI diagnostic fix that changes timeout classification from total job time to per-attempt time. Existing review, retry, permissions, and non-blocking behavior remain unchanged, with no production or schema impact.

Not approved because:

  • Credit balance exhausted. Approvability relies on correctness review in order to determine eligibility

Review your spending limits in Billing settings. You can add or adjust custom eligibility rules. Learn more.

@gitar-bot

gitar-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

Note

Automatic reviews are paused because your team has used its included automatic processing for this billing period (headroom scales with your seat count). You can still comment "Gitar review" to run one anytime, and automatic reviews resume on their own by September 1. Add seats for more headroom.
Learn more

Code Review ✅ Approved

Refactors PR-agent timeout classification to evaluate duration per attempt rather than total job time, preventing false hang reports on slow retries. No issues found.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Attempt2 end stamped unconditionally 🐞 Bug ≡ Correctness
Description
stamp attempt 2 end runs with if: always() even when attempt 2 never started, so ATTEMPT2_END
can be set while ATTEMPT2_START is unset and A2 becomes a huge epoch-seconds duration. If the
verdict duration math runs in any scenario where the retry did not actually execute, the classifier
can incorrectly report a timeout/hang due to the inflated A2.
Code

.github/workflows/pr-agent.yml[R167-169]

+      - name: stamp attempt 2 end
+        if: always()
+        run: echo "ATTEMPT2_END=$(date +%s)" >> "$GITHUB_ENV"
Evidence
The PR adds an unconditional attempt-2 end stamp, while attempt-2 start remains conditional; the
verdict computes A2 as END - START with START defaulting to 0 when unset, which can inflate
A2 dramatically if only END is present.

.github/workflows/pr-agent.yml[136-139]
.github/workflows/pr-agent.yml[167-169]
.github/workflows/pr-agent.yml[206-212]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The workflow stamps `ATTEMPT2_END` unconditionally (`if: always()`), but `ATTEMPT2_START` is only set when attempt 2 actually starts. This creates a state where `ATTEMPT2_END` is populated and `ATTEMPT2_START` is unset, and the verdict’s `A2=$(( end - start ))` calculation can become an enormous epoch-seconds value, contaminating `LONGEST` and causing misclassification.

### Issue Context
Attempt 2 is only supposed to exist when `steps.agent.outcome == 'failure'`, but the end stamp currently runs even when attempt 2 was never started.

### Fix Focus Areas
- .github/workflows/pr-agent.yml[136-139]
- .github/workflows/pr-agent.yml[167-169]
- .github/workflows/pr-agent.yml[206-212]

### Suggested fix
- Make `stamp attempt 2 end` conditional, e.g. `if: steps.agent.outcome == 'failure'` (and optionally also require that attempt2 start happened).
- Additionally harden the verdict math so `A2=0` unless both `ATTEMPT2_START` and `ATTEMPT2_END` are set (or unless `ATTEMPT2_START>0`). This prevents any future reordering/partial execution from inflating `A2`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Slack can mislabel timeouts 🐞 Bug ≡ Correctness
Description
The verdict treats any attempt lasting ≥ STEP_BUDGET_S - 15 seconds as “TIMED OUT … killed by its
step timeout,” which is not guaranteed (an attempt can legitimately fail with an upstream error at
345–359s). This reintroduces confidently-wrong classification on slow failures, undermining the
stated goal of accurate cause labeling.
Code

.github/workflows/pr-agent.yml[R213-216]

+          # SLACK because a step killed AT its timeout records a hair under the
+          # budget — the runner's kill is not instantaneous.
+          SLACK=15
+          if [ "$LONGEST" -ge $(( STEP_BUDGET_S - SLACK )) ]; then
Evidence
The PR explicitly subtracts 15 seconds from the per-attempt budget when deciding whether to print
the “TIMED OUT … killed by its step timeout” message; with a 360s budget, attempts lasting 345–359s
will be labeled as timeouts even though they are still under the configured timeout.

.github/workflows/pr-agent.yml[89-103]
.github/workflows/pr-agent.yml[176-182]
.github/workflows/pr-agent.yml[213-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The classifier uses `SLACK=15` and triggers the “TIMED OUT … killed by its step timeout” message when `LONGEST >= STEP_BUDGET_S - SLACK`. That condition can be true even when the step was not killed by the timeout (e.g., an upstream error returned after 350s), so the message can still be confidently wrong.

### Issue Context
`STEP_BUDGET_S` is set to 360s and corresponds to `timeout-minutes: 6`. The current threshold is effectively 345s, but only durations at/near the actual timeout boundary should justify the hard claim “killed by its step timeout.”

### Fix Focus Areas
- .github/workflows/pr-agent.yml[181-182]
- .github/workflows/pr-agent.yml[213-217]

### Suggested fix
- Reduce slack to a minimal value (e.g. 1–3s) or compute slack as a small percentage capped to a few seconds.
- Alternatively (or additionally) change the warning wording to avoid asserting a timeout as fact when using a slack-based heuristic (e.g., “likely hit the step timeout” / “duration was within Xs of the timeout”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ REVIEW.md
Review mode: ⚖️ Balanced: This is a behavioral CI workflow change affecting timeout classification across retry paths; it is localized but has enough conditional timing and failure-handling logic to warrant a complete single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +167 to +169
- name: stamp attempt 2 end
if: always()
run: echo "ATTEMPT2_END=$(date +%s)" >> "$GITHUB_ENV"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Attempt2 end stamped unconditionally 🐞 Bug ≡ Correctness

stamp attempt 2 end runs with if: always() even when attempt 2 never started, so ATTEMPT2_END
can be set while ATTEMPT2_START is unset and A2 becomes a huge epoch-seconds duration. If the
verdict duration math runs in any scenario where the retry did not actually execute, the classifier
can incorrectly report a timeout/hang due to the inflated A2.
Agent Prompt
### Issue description
The workflow stamps `ATTEMPT2_END` unconditionally (`if: always()`), but `ATTEMPT2_START` is only set when attempt 2 actually starts. This creates a state where `ATTEMPT2_END` is populated and `ATTEMPT2_START` is unset, and the verdict’s `A2=$(( end - start ))` calculation can become an enormous epoch-seconds value, contaminating `LONGEST` and causing misclassification.

### Issue Context
Attempt 2 is only supposed to exist when `steps.agent.outcome == 'failure'`, but the end stamp currently runs even when attempt 2 was never started.

### Fix Focus Areas
- .github/workflows/pr-agent.yml[136-139]
- .github/workflows/pr-agent.yml[167-169]
- .github/workflows/pr-agent.yml[206-212]

### Suggested fix
- Make `stamp attempt 2 end` conditional, e.g. `if: steps.agent.outcome == 'failure'` (and optionally also require that attempt2 start happened).
- Additionally harden the verdict math so `A2=0` unless both `ATTEMPT2_START` and `ATTEMPT2_END` are set (or unless `ATTEMPT2_START>0`). This prevents any future reordering/partial execution from inflating `A2`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +213 to +216
# SLACK because a step killed AT its timeout records a hair under the
# budget — the runner's kill is not instantaneous.
SLACK=15
if [ "$LONGEST" -ge $(( STEP_BUDGET_S - SLACK )) ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Slack can mislabel timeouts 🐞 Bug ≡ Correctness

The verdict treats any attempt lasting ≥ STEP_BUDGET_S - 15 seconds as “TIMED OUT … killed by its
step timeout,” which is not guaranteed (an attempt can legitimately fail with an upstream error at
345–359s). This reintroduces confidently-wrong classification on slow failures, undermining the
stated goal of accurate cause labeling.
Agent Prompt
### Issue description
The classifier uses `SLACK=15` and triggers the “TIMED OUT … killed by its step timeout” message when `LONGEST >= STEP_BUDGET_S - SLACK`. That condition can be true even when the step was not killed by the timeout (e.g., an upstream error returned after 350s), so the message can still be confidently wrong.

### Issue Context
`STEP_BUDGET_S` is set to 360s and corresponds to `timeout-minutes: 6`. The current threshold is effectively 345s, but only durations at/near the actual timeout boundary should justify the hard claim “killed by its step timeout.”

### Fix Focus Areas
- .github/workflows/pr-agent.yml[181-182]
- .github/workflows/pr-agent.yml[213-217]

### Suggested fix
- Reduce slack to a minimal value (e.g. 1–3s) or compute slack as a small percentage capped to a few seconds.
- Alternatively (or additionally) change the warning wording to avoid asserting a timeout as fact when using a slack-based heuristic (e.g., “likely hit the step timeout” / “duration was within Xs of the timeout”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Qodo Fixer

✅ Merged (0) · ☑ Fixed (0)

Process

  • No fixes were applied (no_fixes_applied)

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @yakimoto, you have reached your weekly rate limit of 250000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes the pr-agent workflow’s timeout classifier by measuring the longest individual attempt instead of total job time, preventing multiple healthy slow attempts plus backoff from being mislabeled as a hang while preserving retry, advisory, and non-blocking behavior.

Sequence diagram for per-attempt timeout classification

sequenceDiagram
    participant Workflow
    participant Agent as PR_Agent
    participant Verdict

    Workflow->>Workflow: stamp attempt 1 start
    Workflow->>Agent: run attempt 1
    Workflow->>Workflow: stamp attempt 1 end
    alt attempt 1 fails
        Workflow->>Workflow: sleep 45
        Workflow->>Workflow: stamp attempt 2 start
        Workflow->>Agent: run attempt 2
        Workflow->>Workflow: stamp attempt 2 end
    end
    Workflow->>Verdict: calculate A1, A2, LONGEST
    alt LONGEST >= STEP_BUDGET_S - 15
        Verdict-->>Workflow: render NEUTRAL: TIMED OUT
    else
        Verdict-->>Workflow: render NEUTRAL: failed / likely rate limit
    end
Loading

Flow diagram for longest-attempt timeout decision

flowchart TD
    A[Agent attempts complete] --> B[Read attempt start and end stamps]
    B --> C[Calculate A1 and A2]
    C --> D[Select LONGEST attempt]
    D --> E{LONGEST >= budget minus 15s slack?}
    E -->|Yes| F[Report TIMED OUT]
    E -->|No| G[Report failed, likely upstream rate limit]
    F --> H[Render NEUTRAL; do not block PR]
    G --> H
Loading

File-Level Changes

Change Details Files
Track and classify timeout duration independently for each pr-agent attempt.
  • Replace the single job-level start timestamp with attempt 1 and attempt 2 start/end timestamps.
  • Record end timestamps with if: always() so timeout-killed attempts are measurable.
  • Classify failures using the longest attempt with 15 seconds of runner-kill slack, while retaining wall time for diagnostics only.
.github/workflows/pr-agent.yml
Correct advisory failure messaging without changing retry or gating behavior.
  • Distinguish slow attempts from upstream failures based on per-attempt durations.
  • Continue rendering NEUTRAL and preserve the existing job identity, triggers, and retry flow.
.github/workflows/pr-agent.yml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

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

Labels

size:M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant