Concurrency and limits
Agent hint
Rules on this page that most often cause invalid routines or surprises:
concurrencyapplies to every trigger: events,on.schedule, Run now (forkline routine run) and custom webhooks. A second Run now while the first run is active isskipped_activeunder the default grouprepositoryandonConflict: skip. Run now always skips, even when the routine saysonConflict: replace, and is limited to 10 real runs a minute.concurrency.grouphas four values:pr,issue,repository,routine.prneeds events that all belong to a pull request,issueneedsissue.openedandcomment.createdonly, otherwise the error isgroup_needs_eventsat/concurrency/group.- Defaults:
onConflictisreplacefor the groupsprandissueandskipforrepositoryandroutine.maxActiveis 10 (1 to 50).limits.maxRunsPerPris 3 (1 to 50): default 3. maxActiveis not a queue. At the cap the new event is not started and not kept: outcomeconcurrency_limited, stageconcurrency.maxRunsPerPrcounts 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 onpr.updatedstops reviewing a pull request after its third run (outcomerun_limit). Raise it (at most 50) to run on every commit.- Pushes and schedules do not cancel earlier runs by default: their group is
repositoryandonConflictisskip. agent.timeoutMinutesis 1 to 240. The worst-case cost of one run istimeoutMinutestimes the runner size multiplier.- Webhook and Run now sizes, rates and edges: 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 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:
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:
# 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:
prwhen every event ispr.*,comment.createdorci.failed, and at least one ispr.*orci.failed;- otherwise
issuewhen every event isissue.openedorcomment.created; - 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 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:
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
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, stagerun_limit, reasonPR #{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.failedrun that belongs to no pull request) are not counted and never hit this limit. - Review on every commit: a routine on
pr.updatedthat must run for every push to a pull request needs a higher value, for examplemaxRunsPerPr: 20. With the default 3, the fourth push and every later one are recorded asrun_limitand 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 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.
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 also409:A routine named `{name}` already exists.Self-hosted installations can change this withCALYPSO_ROUTINES_MAX_PUBLISHED_PER_OWNERandCALYPSO_ROUTINES_MAX_PER_OWNER. - A prompt is at most 32 KiB, a routine has at most 8 events, and
include/excludelists 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 (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) |
| 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 is skipped with
skipped_active, whateveronConflictsays. - Credits. The Run now estimate is the most the runs can cost (
timeoutMinutesat the runner size). Most runs finish sooner and cost less. - Answers. An accepted webhook call answers
200, also when nothing starts: thentask_idsis empty,reasonsays why, andevent_idfinds the call in Recent events. An unknown routine, a draft, a disabled routine and a routine that is not a webhook routine all answer404. - 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. Usewhenand 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
githuborsentrypreset, the routine answers401 Missing signatureuntil 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.argsorpush.filesis refused with422event_fields_not_stored. A replay of an event whose repository is no longer registered in Forkline is refused with422event_repository_unavailable. Details in Run now.