---
title: "Concurrency and limits"
description: "How runs for the same pull request or issue interact, and the limits per run, routine and owner."
section: "routines"
url: "https://forkline.dev/docs/routines/concurrency-and-limits"
markdown: "https://forkline.dev/docs/routines/concurrency-and-limits.md"
source: "src/content/docs/routines/concurrency-and-limits.mdx"
---

# Concurrency and limits

> [!TIP]
> **Agent hint**
> Rules on this page that most often cause invalid routines or surprises:
>
> - `concurrency` applies to every trigger: events, `on.schedule`, Run now (`forkline routine run`) and custom webhooks. A second Run now while the first run is active is `skipped_active` under the default group `repository` and `onConflict: skip`. Run now **always** skips, even when the routine says `onConflict: replace`, and is limited to 10 real runs a minute.
> - `concurrency.group` has four values: `pr`, `issue`, `repository`, `routine`. `pr` needs events that all belong to a pull request, `issue` needs `issue.opened` and `comment.created` only, otherwise the error is `group_needs_events` at `/concurrency/group`.
> - Defaults: `onConflict` is `replace` for the groups `pr` and `issue` and `skip` for `repository` and `routine`. `maxActive` is 10 (1 to 50). `limits.maxRunsPerPr` is 3 (1 to 50): default 3.
> - `maxActive` is **not a queue**. At the cap the new event is not started and not kept: outcome `concurrency_limited`, stage `concurrency`.
> - `maxRunsPerPr` counts **every** run the routine started for the pull request, including runs that were replaced (`superseded`), and it never resets. With the default 3, a routine on `pr.updated` stops reviewing a pull request after its third run (outcome `run_limit`). Raise it (at most 50) to run on every commit.
> - Pushes and schedules do **not** cancel earlier runs by default: their group is `repository` and `onConflict` is `skip`.
> - `agent.timeoutMinutes` is 1 to 240. The worst-case cost of one run is `timeoutMinutes` times the runner size multiplier.
> - Webhook and Run now sizes, rates and edges: [Webhook and Run now limits](#webhook-and-run-now-limits).

A routine can be triggered many times while earlier runs are still going. Three settings decide what happens, and an owner-wide cap sits on top:

| Setting | Question it answers | Default |
|---------|--------------------|---------|
| [`concurrency.group`](#concurrency) and `concurrency.onConflict` | A new event arrives for the same pull request, issue or repository while a run is active: cancel the old run or ignore the new event? | group from the events, `replace` for `pr` and `issue`, `skip` otherwise |
| `concurrency.maxActive` | How many runs of this routine may be active at once? | 10 |
| `limits.maxRunsPerPr` | How many runs may this routine start for one pull request, in total? | 3 |

## Concurrency

The block is written like this:

```yaml routine
name: concurrency-shape
description: Every concurrency key with its default
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
agent:
  prompt: Review pull request #{{ pr.number }}.
concurrency:
  group: pr            # pr, issue, repository or routine
  onConflict: replace  # replace or skip
  maxActive: 10        # 1 to 50
```

All three keys are optional, and so is the whole block. A routine without it gets the defaults below. `forkline routine validate` and the validate API show the resolved values in `effective.concurrency`.

### The group

The **group** says which events count as "the same thing". Two runs of one routine conflict when they have the same group key.

| `group` | Allowed when | Group key at run time |
|---------|--------------|-----------------------|
| `pr` | every listed event is `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `comment.created` or `ci.failed` | the repository and the pull request number. A comment on an issue uses the issue number instead. Without either (a `ci.failed` run that belongs to no pull request) the key is the repository |
| `issue` | every listed event is `issue.opened` or `comment.created` | the repository and the issue number. A comment on a pull request uses the pull request number instead. Without either the key is the repository |
| `repository` | always | the repository |
| `routine` | always | the routine: one key for all repositories |

A comment is keyed by its thread under both groups, so a `/fix` comment on one pull request never replaces a run on another pull request. A group that does not fit the events is a validation error at `/concurrency/group` with code `group_needs_events` (the code may be absent, the pointer is always there). For example `group: pr` on a routine that listens to `push` and `pr.opened`:

```yaml routine-invalid pointer=/concurrency/group code=group_needs_events
# Rejected with group_needs_events at /concurrency/group
name: bad-group
description: Group pr cannot work with a push event
on:
  events: [push, pr.opened]
repositories:
  include: all
agent:
  prompt: Look at the change.
concurrency:
  group: pr
```

The message has the form ``group `{group}` needs a routine that triggers on {allowed events}, use `repository` or `routine` instead``. For `pr` the allowed events are "only pull request events, `comment.created` and `ci.failed`", for `issue` "only `issue.opened` and `comment.created`". Fix it by choosing `repository` or `routine`, or by listing only events that fit the group.

**Default group.** When `group` is not set, Forkline picks the narrowest group that fits:

1. `pr` when every event is `pr.*`, `comment.created` or `ci.failed`, and at least one is `pr.*` or `ci.failed`;
2. otherwise `issue` when every event is `issue.opened` or `comment.created`;
3. otherwise `repository`.

Routines started by `on.schedule` or `on.webhook` always default to `repository`. Examples: `[pr.opened, pr.updated]` gives `pr`; `[comment.created]` alone gives `issue` (no event proves a pull request); `[comment.created, ci.failed]` gives `pr`; `[push]` gives `repository`; `[issue.opened, comment.created]` gives `issue`.

### What happens on a conflict

`onConflict` applies when a new event has the same group key as a run of the same routine that is still active (pending, claimed or running). A run on hold (suspended while the account is out of credits) also counts, because it resumes when credits are back: it blocks a `skip` routine and is replaced by `replace`.

| `onConflict` | Default for | Effect | Recorded as |
|--------------|-------------|--------|-------------|
| `replace` | the groups `pr` and `issue` | The older active run for the same group key is cancelled as soon as the new one is created, and the new run starts. Runs created by the **same occurrence** are never cancelled. If the new run cannot start because the [owner pending cap](#the-owner-pending-cap) is reached, **nothing is cancelled**: the older run keeps going. A replaced run that still finishes posts no result: its late comment or review is dropped. Other cancelled runs post as before. | the older run shows as `superseded` (derived when you read it, never stored); the new one is `task_created` |
| `skip` | the groups `repository` and `routine` | The new event is not started while an older run is active. | `skipped_active` at stage `concurrency` |

The editor says:

- Replace: "Cancels the older run as soon as the new one is created. You're billed for the minutes it already ran."
- Skip: "The new event is recorded as skipped in Activity."

So a replaced run's minutes up to the cancellation are still charged; a cancelled run stops consuming credits from that moment.

**Why the defaults differ.** `replace` suits reviews: only the latest commit of a pull request matters, so a new push cancels the review of the previous one. `skip` suits the groups that are not one pull request or issue (`repository` and `routine`): a push to `main` does not cancel the run for the previous push, and a weekly schedule does not cancel last week's run that is still going.

An example of the choice, a fix routine that must not be interrupted:

```yaml routine
name: fix-ci-no-interrupt
description: Fix failed CI without cancelling a fix that is still running
on:
  events: [ci.failed]
repositories:
  include: all
agent:
  prompt: Fix the failing CI run on {{ repository.fullName }}.
concurrency:
  group: pr
  onConflict: skip
```

### Active runs limit

`maxActive` (1 to 50, default 10) is the most runs of this routine that may be active at once, across all its repositories. **Active** means a task that is pending, claimed, running or suspended.

Tasks from the **same occurrence** (one event, one schedule tick, one Run now or one custom webhook call) are not counted against each other, so one occurrence that fans out over many repositories is not cut off at `maxActive`: a schedule over 12 repositories creates 12 tasks with the default 10. Only tasks from earlier occurrences count.

At the cap the new event is **not started and not queued**. It is recorded with outcome `concurrency_limited` at stage `concurrency`, and the reason reads:

`{n} runs of this routine were already active (limit {max}). This event was not run.` With one run it reads `1 run of this routine was already active (limit 1). This event was not run.`

The editor says: `At most {n} runs active at once. Extra events are not queued; they show as Limit reached.` The event is gone: if it matters, run the routine again (a new push, or `forkline routine run` for a webhook or schedule routine) once a run has finished. Raise `maxActive` if you see this often.

With `replace`, the older runs the new run cancels are not counted against `maxActive`.

Order of checks for a new run: a repeated delivery of the same event (a duplicate key), the group conflict (`replace` or `skip`), `maxActive`, `maxRunsPerPr`, credits, and last the account's pending cap.

### The owner pending cap

Separate from `maxActive`, every account has a cap on **pending** runs across all its routines and tasks. When it is reached, a new routine run is not started: outcome `concurrency_limited`, but at stage `submit` instead of `concurrency`. **No task is created.** With `onConflict: replace`, the cap is checked before the older run is cancelled, so a run that cannot start never cancels the one that is running. The reason reads:

`Your account already has {n} pending runs (limit {max}), so this run was not started.` With one run it reads `Your account already has 1 pending run (limit 1), so this run was not started.`

So `concurrency_limited` has two meanings, told apart by the `stage`:

| Outcome | Stage | Meaning | Task |
|---------|-------|---------|------|
| `concurrency_limited` | `concurrency` | this routine reached `maxActive` | none |
| `concurrency_limited` | `submit` | the account's pending-run cap was reached | none |

The cap is not a routine setting: 50 pending runs per account by default, counting only runs waiting to start. Self-hosted installations change it with `RUNNER_SLOT_MAX_PENDING`.

## Run limits

```yaml routine
name: bounded-reviews
description: Review pull requests, at most 3 runs per pull request
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
agent:
  prompt: Review pull request #{{ pr.number }}.
  timeoutMinutes: 30
limits:
  maxRunsPerPr: 3
```

`limits.maxRunsPerPr` (1 to 50, **default 3**) is the most runs one routine starts for the same pull request. It stops loops between a routine and a bot or CI, for example an agent whose own push fails CI again and starts the next run.

- It counts **every** run the routine created for the pull request, **including runs that were replaced** (`superseded`). Replacing a run does not give the budget back.
- The run that would exceed it is not started: outcome `run_limit`, stage `run_limit`, reason `PR #{number} already had {count} runs from this routine (limit {max} per pull request).`
- It applies to events that carry a pull request. Events without one (issues, pushes, schedules, webhooks, and a `ci.failed` run that belongs to no pull request) are not counted and never hit this limit.
- **Review on every commit:** a routine on `pr.updated` that must run for every push to a pull request needs a higher value, for example `maxRunsPerPr: 20`. With the default 3, the fourth push and every later one are recorded as `run_limit` and start nothing.

The editor says: `At most {n} runs per pull request. Counts every run this routine started on the PR.`

### Pushes, schedules, Run now and webhooks do not cancel earlier runs

The default group for `push`, `on.schedule` and `on.webhook` is `repository` and the default `onConflict` is `skip`. A second push, the next schedule tick, a second custom webhook call or a second Run now while a run is active is recorded as `skipped_active`; it never cancels the earlier run. `concurrency` applies to every trigger, so Run now and custom webhooks are no way around it. Set `onConflict: replace` explicitly if you want the newest call to win; Run now still skips.

## Run time, size and credits

Each run has a timeout (`agent.timeoutMinutes`, **1 to 240**, default 30) and a runner size (`agent.runnerSize`: `basic`, `advanced` or `pro`). A run stops when the timeout is reached. Credits are charged for the time a run actually uses, so the **worst case** for one run is:

`credits per run (max) = timeoutMinutes × size multiplier`

| `runnerSize` | Multiplier | Worst case at 30 minutes |
|--------------|------------|--------------------------|
| `basic` | 1 | 30 credits |
| `advanced` | 2 | 60 credits |
| `pro` | 4 | 120 credits |

[Backtest](https://forkline.dev/docs/routines/testing-and-activity#backtest) turns this into a weekly estimate: `ceil(runner sessions per week) × credits per run (max)`. The estimate is an upper bound.

When the balance is empty, runs are refused with outcome `credit_exhausted` at stage `credits` and the reason `Your credit balance is empty. Add credits to resume.`

## Overload protection

When Forkline is overloaded it drops incoming events before evaluating them. Dropped events are not parsed, so they have no summary. Forkline counts the drops per routine and writes **one** record per routine every 60 seconds with outcome `dropped_overload` and stage `overload`, and the reason:

`Forkline was overloaded and dropped {n} events for this routine between {t1} and {t2}.`

`POST /routines/{id}/run` (a real run or a dry run), `POST /routines/test`, `POST /routines/{id}/test` and `POST /webhooks/custom/{id}` answer the same situation with `429` and `retry_after_secs: 5` (and a `Retry-After: 5` header): `Forkline is busy; retry shortly`. Nothing is recorded for a 429, because the caller got an answer. A Run now, dry run or test refused this way does not count toward its rate limit; a webhook call has already used its token. Retry after the delay.

## Fan-out cap

A schedule or webhook routine runs once per selected repository and must select at most **200** repositories. A routine that grows above 200 later is refused as a whole with outcome `fan_out_refused` at stage `fan_out`. Event routines are not limited. See [Repository selection](https://forkline.dev/docs/routines/repositories#fan-out).

## Account limits

- Each owner can have at most **25 published** routines and **100 routines** in total (drafts and disabled routines count). Creating beyond the total, or publishing or re-enabling beyond the published limit, is refused with `409`. Creating or renaming a routine to a name you already use is also `409`: ``A routine named `{name}` already exists.`` Self-hosted installations can change this with `CALYPSO_ROUTINES_MAX_PUBLISHED_PER_OWNER` and `CALYPSO_ROUTINES_MAX_PER_OWNER`.
- A prompt is at most 32 KiB, a routine has at most 8 events, and `include` / `exclude` lists at most 50 entries.

## API rate limits

Per owner, per Forkline replica, in a fixed window of 60 seconds. Over the limit the API answers `429` with `retry_after_secs` and a `Retry-After` header.

| Endpoint | Limit | Request body |
|----------|-------|--------------|
| `POST /routines/validate` | 120 per minute | at most 256 KiB (`413` above) |
| `POST /routines/test` and `POST /routines/{id}/test` | 30 per minute, shared with dry runs | at most 6 MiB, and the payload at most 5 MiB |
| `POST /routines/{id}/run` | 10 real runs per minute; dry runs count toward the test limit | at most 6 MiB, and the payload at most 5 MiB |
| `POST /routines/backtest` and `POST /routines/{id}/backtest` | 60 per minute (the web editor waits 600 ms after each change) | at most 256 KiB |
| `POST /routines/drafts/from-event` | 30 per minute | at most 16 KiB (`413` above) |
| `GET /routines/{id}/runs`, `GET /routines/events` | none beyond the global limits | |
| `POST /webhooks/custom/{id}` | none per owner; per routine and per sender IP, see [below](#webhook-and-run-now-limits) (`429` while Forkline is overloaded) | at most 5 MiB (`413` `PAYLOAD_TOO_LARGE` above) |

`validate`, `test`, `backtest` and `drafts/from-event` have no side effects and need only the `routines:read` scope.

## Webhook and Run now limits

| What | Limit | Beyond it |
|------|-------|-----------|
| Webhook request body | 5 MiB | `413` `PAYLOAD_TOO_LARGE`: `The request body is larger than 5 MiB`. Nothing starts |
| Run now `payload`, also on a dry run | 5 MiB, measured as compact JSON | `413` `PAYLOAD_TOO_LARGE`, for example `Payload is 5.4 MiB; the limit is 5 MiB.` Nothing starts |
| Test `payload` or `body` | 5 MiB | The same `413` |
| Run now and test request | 6 MiB | `413` `The request body is larger than 6 MiB` |
| Fields `filters.when` reads from `payload` | weight 4,096, depth 16, 1,000 items, 64 KiB of text | Outcome `condition_error` at stage `when`; the reason names the field ([what counts](https://forkline.dev/docs/routines/filters#reading-the-webhook-payload-with-payload)) |
| Real runs from Run now | 10 per minute per owner, for all routines together | `429` with `Retry-After`. A request refused before it is evaluated does not count: an invalid request, a draft, a repository that is not selected, too many repositories, an unknown event, an event that is not a forge event, an event whose repository is not registered, a request that does not fit the trigger, an invalid spec, a payload over the limit, or Forkline busy. A real run that its filters refuse (`422` `filters_not_matched` or `event_fields_not_stored`) counts |
| Dry runs and tests | 30 per minute per owner, shared by `run` with `dry_run: true`, `POST /routines/test` and `POST /routines/{id}/test` | `429` with `Retry-After`. A request refused before it is evaluated does not count: an invalid request or spec, an unknown routine, event or repository, an event that is not a forge event, an event whose repository is not registered, a request that does not fit the trigger, a payload over the limit, or Forkline busy. A dry run or test that is evaluated counts, whatever its outcome |
| Calls waiting for a run slot, per Forkline replica | 32 MiB of payloads in total, 50 calls per owner | `429` `Forkline is busy; retry shortly` with `Retry-After: 5`, for webhook calls, Run now, dry runs and tests. A webhook call counts by its body, a Run now or dry run by its payload, a test by its request. A Run now, dry run or test refused this way does not count toward its rate limit; a webhook call has already used its token |
| Webhook calls | 600 per minute per routine (bursts of 200), 1,200 per minute per sender IP (bursts of 400) | `429` `Webhook rate limit exceeded; retry later` with `Retry-After`. Nothing starts |
| Repositories per run | 200 | `422` `fan_out_too_large` |
| `Idempotency-Key` (or the configured header) | 200 bytes | A longer or empty key is ignored, so the call runs |
| Webhook `example` | 16 KiB | Refused when you save |
| Events you can replay | Forge events of the last 7 days, up to 5,000 per owner | Older events are deleted |

The Run now, dry run and test rates are fixed windows of 60 seconds, counted per Forkline replica, like the API rate limits above. The webhook rates are token buckets, counted per Forkline replica. A bucket starts full at the burst size, each call takes one token, and tokens come back at the per-minute rate. With no token left the call gets `429`, and `Retry-After` is the seconds until the next token. The per-IP bucket counts every request to the webhook URL, signed or not. The per-routine bucket counts only calls whose signature or token checks out.

### How webhooks and Run now behave

- **Who.** Only a routine's owner can run it, dry-run it, replay an event into it, or set and rotate its secret. For anyone else the routine does not exist (`404`).
- **Drafts and disabled routines.** A draft can be dry-run; publish it to run it. A disabled routine runs from Run now, while events, its schedule and its webhook don't start it.
- **Runs already active.** Run now and webhooks never cancel work: a repository with an active run in the same [group](#the-group) is skipped with `skipped_active`, whatever `onConflict` says.
- **Credits.** The Run now estimate is the most the runs can cost (`timeoutMinutes` at the runner size). Most runs finish sooner and cost less.
- **Answers.** An accepted webhook call answers `200`, also when nothing starts: then `task_ids` is empty, `reason` says why, and `event_id` finds the call in [Recent events](https://forkline.dev/docs/routines/testing-and-activity#recent-events). An unknown routine, a draft, a disabled routine and a routine that is not a webhook routine all answer `404`.
- **Repeats.** A signature covers the body only, with no timestamp, so an identical request is accepted again. Send an idempotency key to make retries safe: a key Forkline has already seen for the routine is `deduplicated`, with no time limit, for as long as the run is kept. Sentry sends no key, so each Sentry delivery, retries included, is a new call. Use `when` and [concurrency](#concurrency) to absorb duplicates.
- **Secrets.** A generated or pasted secret is shown once and never again; rotate it to get a new one. After a switch to the `github` or `sentry` preset, the routine answers `401 Missing signature` until you paste that vendor's secret.
- **Event replay.** A replay sees what Forkline keeps about an event: no bodies, comment text or file lists. A routine that reads `pr.body`, `issue.body`, `comment.body`, `comment.command.args` or `push.files` is refused with `422` `event_fields_not_stored`. A replay of an event whose repository is no longer registered in Forkline is refused with `422` `event_repository_unavailable`. Details in [Run now](https://forkline.dev/docs/routines/testing-and-activity#run-now).
