Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/review-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,13 @@ jobs:
(needs.resolve-context.outputs.requested-reviewer == 'docker-agent' || needs.resolve-context.outputs.requested-reviewer == ''))
)
runs-on: ubuntu-latest
timeout-minutes: 50
# Budget: review agent hard-capped at 2700 s total + feedback processing at
# 300 s (both runner-enforced via total-timeout in review-pr/action.yml),
# leaving ~10 min for preamble and cleanup. The agent can never outlive its
# own budget, so this ceiling is a safety net that should never fire — which
# keeps the composite's always() steps (lock release, summary, reactions)
# running on every path.
timeout-minutes: 60
permissions:
contents: read
pull-requests: write
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ The action runs untrusted input (PR titles, bodies, comments, diffs) through an

### `review-pr` action specifics

- Uses a **best-effort cache lock** (`pr-review-lock-<repo>-<pr>-*` cache key) to avoid concurrent reviews on the same PR. Lock TTL is 600s; the agent execution timeout is 1800s (30 min) — these are intentionally decoupled. Reviews are idempotent so the small race window is acceptable.
- Uses a **best-effort cache lock** (`pr-review-lock-<repo>-<pr>-*` cache key) to avoid concurrent reviews on the same PR. Lock TTL is 600s; the review agent's wall-clock budget is 2700s (45 min, enforced by the root action's `total-timeout` across all attempts) — these are intentionally decoupled. Reviews are idempotent so the small race window is acceptable.
- **Memory persistence** uses `actions/cache` keyed by `pr-review-memory-<repo>-<job>-<run_id>` with prefix-based restore. The DB lives at `${{ github.workspace }}/.cache/pr-review-memory.db`.
- **Feedback loop**: the `reply-to-feedback` job in `.github/workflows/review-pr.yml` (which runs the `pr-review-reply.yaml` agent) uploads a `pr-review-feedback` artifact on every reply via its "Upload feedback artifact" step. The next review run downloads all such artifacts, runs `pr-review-feedback.yaml` to call `add_memory(...)` for each, then deletes the artifacts.
- **Bot reply detection** uses HTML markers: `<!-- docker-agent-review -->` on review comments, `<!-- docker-agent-review-reply -->` on agent replies (including mention-reply responses). **Don't change these strings** — workflows in consumer repos grep for them.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ To report a vulnerability, see our [Security Policy](SECURITY.md).
| `yolo` | Auto-approve all prompts (`true`/`false`) | No | `true` |
| `max-retries` | Maximum number of retries on failure (0 = no retries) | No | `2` |
| `retry-delay` | Base delay in seconds between retries (doubles each attempt) | No | `5` |
| `total-timeout` | Total wall-clock budget in seconds across all attempts and retry delays (0 = unlimited) | No | `0` |
| `no-retry-pattern` | Regex tested against the agent log after a failed attempt; on match, retries are skipped | No | - |
| `extra-args` | Additional arguments to pass to `docker agent run` | No | - |
| `add-prompt-files` | Comma-separated list of files to append to the prompt (e.g., `AGENTS.md,CLAUDE.md`) | No | - |
| `skip-summary` | Skip writing agent output to the job summary (useful when callers write their own) | No | `false` |
Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ inputs:
description: "Number of additional retry attempts when the agent times out (exit code 124). Independent of max-retries — both budgets can be consumed in the same run. Default 0 = no timeout retries."
required: false
default: "0"
total-timeout:
description: "Total wall-clock budget in seconds across all attempts, retry delays included (0 = unlimited). The last attempt is capped to the remaining budget and a retry only starts with at least 60 s left, so the action always finishes within this budget. Set it below the job's timeout-minutes to keep post-run cleanup steps alive."
required: false
default: "0"
no-retry-pattern:
description: "Regex tested against the verbose agent log after a failed attempt. On match, remaining retries are skipped — use it when a retry would repeat a side effect the failed attempt already committed (e.g. 'pullrequestreview-[0-9]+' once a PR review was posted). Empty = disabled."
required: false
default: ""
extra-args:
description: "Additional arguments to pass to docker agent run"
required: false
Expand Down
18 changes: 13 additions & 5 deletions review-pr/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ runs:
LOCK_TIME=$(cat .cache/review-lock/timestamp 2>/dev/null || echo "0")
NOW=$(date +%s)
AGE=$(( NOW - LOCK_TIME ))
if [ "$AGE" -lt 600 ]; then # 600 s lock TTL — intentionally decoupled from the review timeout (now 1800 s)
if [ "$AGE" -lt 600 ]; then # 600 s lock TTL — intentionally decoupled from the review agent's 2700 s total budget
echo "⏭️ Review already in progress (started ${AGE}s ago) — skipping"
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip-reason=concurrent" >> $GITHUB_OUTPUT
Expand Down Expand Up @@ -694,7 +694,8 @@ runs:
3. If they're agreeing and adding context, store the additional insight

Use add_memory to record what you learned from each feedback item.
timeout: "180" # 3 min — haiku call; prevents hang from eating the 35-min job budget
timeout: "180" # 3 min per attempt — haiku call
total-timeout: "300" # 5 min hard cap across attempts so feedback processing can't eat the review budget
anthropic-api-key: ${{ inputs.anthropic-api-key }}
openai-api-key: ${{ inputs.openai-api-key }}
google-api-key: ${{ inputs.google-api-key }}
Expand Down Expand Up @@ -867,7 +868,14 @@ runs:
with:
agent: ${{ env.ACTION_PATH }}/agents/pr-review.yaml
prompt: ${{ steps.context.outputs.review_prompt }}
timeout: "2700" # 45 min — allows complex reviews to complete; lock TTL (600 s) is intentionally shorter
# Budget: total-timeout (2700 s) is the hard wall-clock cap for this step,
# attempts and retry delays included, so it always fits the caller's job
# timeout with room for the cleanup steps below (lock release, summary,
# reactions). No retry-on-timeout: a second 45-min pass can never fit the
# same budget, so a timed-out review is surfaced to the PR for a manual
# re-request instead of silently burning a doomed retry.
timeout: "2700" # 45 min per attempt — allows complex reviews to complete; lock TTL (600 s) is intentionally shorter
total-timeout: "2700"
anthropic-api-key: ${{ inputs.anthropic-api-key }}
openai-api-key: ${{ inputs.openai-api-key }}
google-api-key: ${{ inputs.google-api-key }}
Expand All @@ -878,8 +886,8 @@ runs:
github-token: ${{ steps.resolve-token.outputs.token }}
extra-args: ${{ inputs.model && format('--model={0}', inputs.model) || '' }}
add-prompt-files: ${{ inputs.add-prompt-files }}
max-retries: "1" # One retry handles transient API failures (e.g. Anthropic 400s) without risking duplicate reviews from full pipeline restarts
retry-on-timeout: "1" # Retry once on timeout — agent may succeed on a second pass after a transient infra hiccup
max-retries: "1" # One retry handles transient API failures (e.g. Anthropic 400s); total-timeout caps the sum of both attempts
no-retry-pattern: "pullrequestreview-[0-9]+" # Never restart the pipeline once a review was posted — a retry would post a duplicate
skip-summary: "true"
org-membership-token: ${{ inputs.org-membership-token }}
auth-org: ${{ inputs.auth-org }}
Expand Down
120 changes: 119 additions & 1 deletion src/main/__tests__/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ vi.mock('node:child_process', () => ({
spawn: mockSpawn,
}));

import { buildArgs, runAgent, TIMEOUT_EXIT_CODE } from '../exec.js';
import { buildArgs, MIN_RETRY_BUDGET_SECONDS, runAgent, TIMEOUT_EXIT_CODE } from '../exec.js';

// ── Helpers ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -70,6 +70,8 @@ function baseOpts(overrides: Partial<Parameters<typeof runAgent>[0]> = {}) {
maxRetries: 0,
retryDelay: 0,
retryOnTimeout: 0,
totalTimeout: 0,
noRetryPattern: '',
debug: false,
anthropicApiKey: 'sk-ant-test',
telemetryTags: 'source=test',
Expand Down Expand Up @@ -390,3 +392,119 @@ describe('runAgent', () => {
expect(envPassed.TELEMETRY_TAGS).toBe('source=ci');
});
});

// ── total-timeout budget ──────────────────────────────────────────────────────────────────

describe('runAgent total-timeout', () => {
it('enforces the total budget as a kill timer even when per-attempt timeout is 0', async () => {
// Child would exit naturally in 5 s; the 50 ms total budget must kill it first.
const child = makeMockChild(0, 5000);
mockSpawn.mockReturnValue(child);

const result = await runAgent(baseOpts({ timeout: 0, totalTimeout: 0.05, maxRetries: 0 }));

expect(result.exitCode).toBe(TIMEOUT_EXIT_CODE);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
}, 3000);

it('skips the retry when the remaining budget is under the minimum floor', async () => {
mockSpawn.mockImplementation(() => makeMockChild(1));

// Budget (10 s) minus elapsed leaves < MIN_RETRY_BUDGET_SECONDS (60) → no retry.
const result = await runAgent(baseOpts({ maxRetries: 2, retryDelay: 0, totalTimeout: 10 }));

expect(result.exitCode).toBe(1);
expect(mockSpawn).toHaveBeenCalledOnce();
expect(MIN_RETRY_BUDGET_SECONDS).toBeGreaterThan(10);
});

it('allows retries while the remaining budget stays above the floor', async () => {
mockSpawn
.mockImplementationOnce(() => makeMockChild(1))
.mockImplementation(() => makeMockChild(0));

const result = await runAgent(baseOpts({ maxRetries: 1, retryDelay: 0, totalTimeout: 300 }));

expect(result.exitCode).toBe(0);
expect(mockSpawn).toHaveBeenCalledTimes(2);
});

it('caps the per-attempt timeout to the remaining budget', async () => {
// totalTimeout (0.05 s) < timeout (600 s): the budget, not the attempt cap,
// must kill the child.
const child = makeMockChild(0, 5000);
mockSpawn.mockReturnValue(child);

const result = await runAgent(baseOpts({ timeout: 600, totalTimeout: 0.05, maxRetries: 0 }));

expect(result.exitCode).toBe(TIMEOUT_EXIT_CODE);
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
}, 3000);
});

// ── no-retry-pattern ──────────────────────────────────────────────────────────────────────

describe('runAgent no-retry-pattern', () => {
it('skips retries when the verbose log matches the pattern', async () => {
mockSpawn.mockImplementation(() => {
// Simulate the agent posting a review before failing.
fsSync.appendFileSync(verboseLogFile, 'posted pullrequestreview-123456\n', 'utf-8');
return makeMockChild(1);
});

const result = await runAgent(
baseOpts({ maxRetries: 2, retryDelay: 0, noRetryPattern: 'pullrequestreview-[0-9]+' }),
);

expect(result.exitCode).toBe(1);
expect(mockSpawn).toHaveBeenCalledOnce();
});

it('skips timeout retries when the verbose log matches the pattern', async () => {
mockSpawn.mockImplementation(() => {
fsSync.appendFileSync(verboseLogFile, 'posted pullrequestreview-123456\n', 'utf-8');
return makeMockChild(TIMEOUT_EXIT_CODE);
});

const result = await runAgent(
baseOpts({
retryOnTimeout: 1,
retryDelay: 0,
noRetryPattern: 'pullrequestreview-[0-9]+',
}),
);

expect(result.exitCode).toBe(TIMEOUT_EXIT_CODE);
expect(mockSpawn).toHaveBeenCalledOnce();
});

it('retries normally when the log does not match', async () => {
mockSpawn
.mockImplementationOnce(() => makeMockChild(1))
.mockImplementation(() => makeMockChild(0));

const result = await runAgent(
baseOpts({ maxRetries: 1, retryDelay: 0, noRetryPattern: 'pullrequestreview-[0-9]+' }),
);

expect(result.exitCode).toBe(0);
expect(mockSpawn).toHaveBeenCalledTimes(2);
});

it('ignores an invalid regex and keeps retrying', async () => {
const { warning } = await import('@actions/core');
mockSpawn
.mockImplementationOnce(() => makeMockChild(1))
.mockImplementation(() => makeMockChild(0));

const result = await runAgent(
baseOpts({ maxRetries: 1, retryDelay: 0, noRetryPattern: '([unclosed' }),
);

expect(result.exitCode).toBe(0);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(vi.mocked(warning)).toHaveBeenCalledWith(
expect.stringContaining('Invalid no-retry-pattern'),
);
});
});
81 changes: 73 additions & 8 deletions src/main/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
* - Keys are registered with core.setSecret() BEFORE any exec call
* - Prompt is passed via stdin (from sanitized file or raw string)
* - stdout + stderr go to verbose log file (keeps runner console clean)
* - Exit code 124 = timeout (no retry)
* - Exit code 124 = timeout (retried only within the retry-on-timeout budget)
* - Retry loop with exponential backoff
* - Optional total-timeout budget spanning all attempts: the last attempt is
* capped to the remaining budget and a retry needs >= 60 s left to start,
* so the loop always ends before a job-level timeout-minutes kill
* - Optional no-retry-pattern: retries are skipped once the verbose log
* proves a side effect already happened (e.g. a PR review was posted)
* - On retry: truncate clean output file, append separator to verbose log
* - SIGTERM on timeout, exit code reported as 124
*/
Expand All @@ -24,6 +29,9 @@ import * as core from '@actions/core';

export const TIMEOUT_EXIT_CODE = 124;

/** Minimum remaining total-timeout budget (seconds) for a retry to be worth starting. */
export const MIN_RETRY_BUDGET_SECONDS = 60;

export interface RunAgentOptions {
/** Absolute path to the docker-agent binary. */
dockerAgentPath: string;
Expand All @@ -49,6 +57,10 @@ export interface RunAgentOptions {
retryDelay: number;
/** Number of additional retry attempts allowed when the agent times out (exit 124). */
retryOnTimeout: number;
/** Total wall-clock budget in seconds across all attempts, retries and delays (0 = unlimited). */
totalTimeout: number;
/** Regex source tested against the verbose log after a failed attempt; on match, retries are skipped. */
noRetryPattern: string;
/** Whether debug mode is enabled. */
debug: boolean;

Expand Down Expand Up @@ -131,6 +143,18 @@ function sleep(seconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

/**
* Test the verbose log against the no-retry pattern. Fails open on read errors:
* an unreadable log cannot prove a side effect happened, so retrying stays allowed.
*/
function verboseLogMatches(logFile: string, pattern: RegExp): boolean {
try {
return pattern.test(fs.readFileSync(logFile, 'utf-8'));
} catch {
return false;
}
}

/**
* Spawn docker-agent as a child process, piping stdin from the prompt
* and stdout+stderr to the verbose log file.
Expand Down Expand Up @@ -277,6 +301,19 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
}

const startTime = Date.now();

let noRetryRegex: RegExp | null = null;
if (opts.noRetryPattern) {
try {
noRetryRegex = new RegExp(opts.noRetryPattern);
} catch {
core.warning(`Invalid no-retry-pattern /${opts.noRetryPattern}/ - ignoring it`);
}
}

const deadline =
opts.totalTimeout > 0 ? startTime + opts.totalTimeout * 1000 : Number.POSITIVE_INFINITY;

let exitCode = 1;
let totalAttempt = 0;
// Track the two retry budgets independently so mixed-failure sequences
Expand Down Expand Up @@ -307,6 +344,15 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
fs.appendFileSync(opts.verboseLogFile, separator, 'utf-8');
}

// Cap the attempt to whatever remains of the total budget so the loop can
// never outlive it (a runner-enforced kill keeps job-level cleanup alive).
// Clamped to >= 1 s: a zero/negative value would disable spawnAgent's timer.
let attemptTimeout = opts.timeout;
if (deadline !== Number.POSITIVE_INFINITY) {
const remaining = Math.max((deadline - Date.now()) / 1000, 1);
attemptTimeout = opts.timeout > 0 ? Math.min(opts.timeout, remaining) : remaining;
}

// Open verbose log fd for appending
const verboseLogFd = fs.openSync(opts.verboseLogFile, 'a');

Expand All @@ -317,7 +363,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
env,
stdinData,
verboseLogFd,
timeoutSeconds: opts.timeout,
timeoutSeconds: attemptTimeout,
});
} finally {
fs.closeSync(verboseLogFd);
Expand All @@ -328,20 +374,39 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
}

if (exitCode === TIMEOUT_EXIT_CODE) {
core.error(`Agent execution timed out after ${opts.timeout} seconds`);
core.error(`Agent execution timed out after ${Math.round(attemptTimeout)} seconds`);
if (timeoutRetryCount >= opts.retryOnTimeout) {
break; // Timeout retry budget exhausted
}
} else if (failureRetryCount >= opts.maxRetries) {
core.warning(`Agent failed after ${opts.maxRetries} retries (exit code: ${exitCode})`);
break;
}

// A retry is available. Cross-cutting guards run before a retry budget is
// consumed so a skipped retry never counts against either budget.
if (noRetryRegex && verboseLogMatches(opts.verboseLogFile, noRetryRegex)) {
core.warning(
'Skipping retry: verbose log matches no-retry-pattern (a side effect of the failed attempt may already be committed)',
);
break;
}
if (deadline !== Number.POSITIVE_INFINITY) {
const remainingAfterDelay = (deadline - Date.now()) / 1000 - currentDelay;
if (remainingAfterDelay < MIN_RETRY_BUDGET_SECONDS) {
core.warning(
`Skipping retry: less than ${MIN_RETRY_BUDGET_SECONDS}s of the ${opts.totalTimeout}s total-timeout budget remains`,
);
break;
}
}

if (exitCode === TIMEOUT_EXIT_CODE) {
timeoutRetryCount++;
core.warning(
`Timeout — will retry (${timeoutRetryCount}/${opts.retryOnTimeout} timeout retries used)`,
);
// fall through to retry
} else {
if (failureRetryCount >= opts.maxRetries) {
core.warning(`Agent failed after ${opts.maxRetries} retries (exit code: ${exitCode})`);
break;
}
failureRetryCount++;
core.warning(`Agent failed (exit code: ${exitCode}), will retry...`);
}
Expand Down
Loading