Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
The Problem
Since PR#117 a build with trigger.onPullRequestTarget judges a pull request, for example an AI review (werkrevisor).
Such a review mostly waits for a language model: little CPU, little memory, about 10–70 s.
But it shared the limits of the CI:
executor.maxConcurrentPerRepository: 1 made the review wait for the CI build of the same repository: about 4 min on mih09, 8–9 min on vm4006.
The serialization per (repository, branch) made it also wait for the CI build of its head branch, although it does not use that branch's worktree.
The verdict should be there after about one minute, and it must not slow down the CI.
Non-Goals
No cancellation of a running review when a new head arrives; a stale run publishes its status on the old head only.
No other kind of build leaves the CI limits.
The Scenarios
Feature: pull-request target builds run in a pool of their own
Background
A review is a pull-request target build, a definition with trigger.onPullRequestTarget: true (PR#117).
The CI pool is executor.maxConcurrent over all repositories and executor.maxConcurrentPerRepository per repository.
The review pool is executor.maxConcurrentPullRequestTargets (default 2) and executor.maxConcurrentPullRequestTargetsPerRepository (default 1).
Scenario#119.01: a review runs beside the CI build of its repository and head branch
So that the verdict does not wait minutes for a CI build it does not need.
GivenmaxConcurrent: 1 and maxConcurrentPerRepository: 1
and a CI build of branch feature/x is running
When a review of a pull request with head feature/x is queued
Then it runs at once, beside the CI build
and it is not serialized with the builds of feature/x, since it runs in a fresh workspace
Verified by
BuildExecutorIntegrationTesta pull-request target build runs while the CI build of its repository and head branch runs (Scenario#119.01)
BuildExecutorUnitTesta review runs beside the CI build of its own repository and head branch (Scenario#119.01)
BuildSlotsUnitTesta review takes no CI slot and waits for none (Scenario#119.01)
Scenario#119.02: the reviews of one repository wait for their own per-repository limit
So that one repository with many open pull requests cannot take every review slot.
GivenmaxConcurrentPullRequestTargetsPerRepository: 1 and maxConcurrentPullRequestTargets: 2
and a review of repository A is running
When a review of another pull request of A is queued
and a review of repository B is queued
Then the second review of A waits until the first ends
and the review of B runs at once
Verified by
BuildSlotsUnitTestthe reviews of one repository wait for their own per-repository limit (Scenario#119.02)
BuildExecutorUnitTesta review waits for the other reviews of its repository (Scenario#119.02)
Scenario#119.03: all reviews wait for their own global limit, which only the instance sets
So that the reviews of all repositories together stay within what the host and the model's quota allow.
GivenmaxConcurrentPullRequestTargets: 2 in ~/.werkator.yml
and two reviews of other repositories are running
When a third review is queued
Then it waits until one of them ends
and a CI build queued at the same time runs at once
And both keys default to 2 and 1, and a branch's .werkator.yml cannot change them
Verified by
BuildSlotsUnitTestthe reviews of all repositories wait for their own global limit, the CI does not (Scenario#119.03)
BuildExecutorUnitTestthe review pool is sized from executor.maxConcurrentPullRequestTargets, apart from maxConcurrent (Scenario#119.03)
ConfigLoaderUnitTestthe pull-request target limits come from the instance file, default to 2 and 1, and no branch reaches them (Scenario#119.03)
Scenario#119.04: neither pool's queue stands behind the other's
So that reviews do not push CI builds aside, and CI builds do not hold reviews back.
Given the review pool is full and another review waits
and the CI pool is full and another CI build waits
When the running review ends
Then the waiting review starts
and the waiting CI build keeps waiting for a CI slot
And a CI build queued while the review pool is full starts at once if a CI slot is free
Verified by
BuildExecutorUnitTesta full review pool leaves the CI its slot, and a full CI pool leaves the reviews theirs (Scenario#119.04)
BuildSlotsUnitTesta full review pool leaves the CI its slots (Scenario#119.04)
Scenario#119.05: the runs of one pull request run one after the other
So that an old head and a new head never share the fresh workspace of their pool at the same time.
Given a review of pull request 7 at its old head is running
and the per-repository limit of the review pool is 2
When a review of pull request 7 at its new head is queued
and a review of pull request 8 is queued
Then the review of pull request 8 runs at once
and the new head of pull request 7 waits until the old head's run ends
Verified by
BuildExecutorUnitTestthe runs of one pull request, old head and new head, run one after the other (Scenario#119.05)
Scenario#119.06: the views show the two pools apart
So that a reader sees which builds take the CI's slots and which the reviews'.
Given a CI build and a review are running
When the System page is shown
Then the Builds tile counts the CI build against maxConcurrent
and a PR targets tile counts the review against maxConcurrentPullRequestTargets, with the same colour rule
When the Current Builds view is shown
Then the review's card carries a PR target tag
Verified by
UiViewsUnitTestthe pull-request targets have a tile of their own beside the builds tile (Scenario#119.06)
SystemApiControllerRestTestthe system endpoint carries the executor's slots, for the builds tile (Scenario#101.03), the running builds are listed with their repository (Scenario#27.05)
BuildsApiControllerRestTestcurrent marks a pull-request target build as one of its own pool (Scenario#119.06)
The werkator.js twins of the tile and the tag were not run in a browser; they follow the same data as the server-rendered page.
Scenario#119.07: export:forgejo serializes the runs of one pull request
So that a converted workflow keeps the one rule a workflow can say, and names the rest (ADR 0010).
Given a definition review with trigger.onPullRequestTarget: true
When it is exported with export:forgejo
Then its workflow has concurrency.group: pr-target-review-${{ github.event.pull_request.number }} and cancel-in-progress: false
and a note says that the pool's size belongs to the runners
and a note says that a concurrency group keeps only the newest waiting run, where Werkator queues every head
Verified by
ForgejoWorkflowExporterUnitTestthe runs of one pull request wait for each other in a concurrency group of their own (Scenario#119.07), a pull-request target build becomes a pull_request_target workflow on the base, skipping drafts (Scenario#117.08)
The Solution
BuildPool names the two pools and reads each pool's two limits from ExecutorConfig. BuildSlots holds one set of fair semaphores per pool: a global one and one per repository.
Fair semaphores per pool are what gives each pool its own queue.
All four limits are sized once, from the first build, like before.
RunningBuild.pool is PULL_REQUEST_TARGET exactly when the build carries a pull request. BuildExecutor.admit takes the slots of that pool.
The serial worker of a build is keyed by its lane: the branch for every build that uses the branch's worktree, pull-request-target:<head-branch>@<build> for a review.
The lane follows the fresh workspace, which is named after the pool name, so two heads of one pull request still run in order.
A : cannot occur in a branch name, so no branch shares a review's lane.
The views read the pool from RunningBuild: CurrentBuildDto.pullRequestTarget, RunningBuildDto.pullRequestTarget and SystemDto.maxConcurrentPullRequestTargets.
The System page renders a second slots tile; werkator.js splits runningBuilds by the flag the same way.
export:forgejo writes a workflow-level concurrency block for a pull-request target workflow.
The group carries the definition's name, because Werkator keeps two definitions for the same pull request apart.
Open Questions
Forgejo's support of concurrency depends on its version; the block is ignored by an older runner, and the notes say what differs.
The PR targets tile shows on every instance, also on one without any pull-request target definition.
Additional Changes
AGENTS.md (hard invariant on RepoContext and the executor limits) and .claude/skills/architecture/SKILL.md describe the two pools.
docs/configuration.md: both keys in the instance example, the key ownership list and the executor reference, and a paragraph "Concurrency" in the notes on trigger.onPullRequestTarget.
Prerequisite PRs
PR#117: trigger.onPullRequestTarget.
PR#118: fresh workspace and home for pull-request target builds.
> Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.
## The Problem
Since PR#117 a build with `trigger.onPullRequestTarget` judges a pull request, for example an AI review (werkrevisor).
Such a review mostly waits for a language model: little CPU, little memory, about 10–70 s.
But it shared the limits of the CI:
- `executor.maxConcurrentPerRepository: 1` made the review wait for the CI build of the same repository: about 4 min on mih09, 8–9 min on vm4006.
- The serialization per (repository, branch) made it also wait for the CI build of its head branch, although it does not use that branch's worktree.
The verdict should be there after about one minute, and it must not slow down the CI.
## Non-Goals
- No cancellation of a running review when a new head arrives; a stale run publishes its status on the old head only.
- No other kind of build leaves the CI limits.
## The Scenarios
### Feature: pull-request target builds run in a pool of their own
#### Background
- A *review* is a pull-request target build, a definition with `trigger.onPullRequestTarget: true` (PR#117).
- The *CI pool* is `executor.maxConcurrent` over all repositories and `executor.maxConcurrentPerRepository` per repository.
- The *review pool* is `executor.maxConcurrentPullRequestTargets` (default 2) and `executor.maxConcurrentPullRequestTargetsPerRepository` (default 1).
#### Scenario#119.01: a review runs beside the CI build of its repository and head branch
So that the verdict does not wait minutes for a CI build it does not need.
- **Given** `maxConcurrent: 1` and `maxConcurrentPerRepository: 1`
- **and** a CI build of branch `feature/x` is running
- **When** a review of a pull request with head `feature/x` is queued
- **Then** it runs at once, beside the CI build
- **and** it is not serialized with the builds of `feature/x`, since it runs in a fresh workspace
##### Verified by
- [BuildExecutorIntegrationTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorIntegrationTest.kt) `a pull-request target build runs while the CI build of its repository and head branch runs (Scenario#119.01)`
- [BuildExecutorUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorUnitTest.kt) `a review runs beside the CI build of its own repository and head branch (Scenario#119.01)`
- [BuildSlotsUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildSlotsUnitTest.kt) `a review takes no CI slot and waits for none (Scenario#119.01)`
#### Scenario#119.02: the reviews of one repository wait for their own per-repository limit
So that one repository with many open pull requests cannot take every review slot.
- **Given** `maxConcurrentPullRequestTargetsPerRepository: 1` and `maxConcurrentPullRequestTargets: 2`
- **and** a review of repository A is running
- **When** a review of another pull request of A is queued
- **and** a review of repository B is queued
- **Then** the second review of A waits until the first ends
- **and** the review of B runs at once
##### Verified by
- [BuildSlotsUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildSlotsUnitTest.kt) `the reviews of one repository wait for their own per-repository limit (Scenario#119.02)`
- [BuildExecutorUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorUnitTest.kt) `a review waits for the other reviews of its repository (Scenario#119.02)`
#### Scenario#119.03: all reviews wait for their own global limit, which only the instance sets
So that the reviews of all repositories together stay within what the host and the model's quota allow.
- **Given** `maxConcurrentPullRequestTargets: 2` in `~/.werkator.yml`
- **and** two reviews of other repositories are running
- **When** a third review is queued
- **Then** it waits until one of them ends
- **and** a CI build queued at the same time runs at once
- **And** both keys default to 2 and 1, and a branch's `.werkator.yml` cannot change them
##### Verified by
- [BuildSlotsUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildSlotsUnitTest.kt) `the reviews of all repositories wait for their own global limit, the CI does not (Scenario#119.03)`
- [BuildExecutorUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorUnitTest.kt) `the review pool is sized from executor.maxConcurrentPullRequestTargets, apart from maxConcurrent (Scenario#119.03)`
- [ConfigLoaderUnitTest](../../src/test/kotlin/de/hoennig/werkator/config/ConfigLoaderUnitTest.kt) `the pull-request target limits come from the instance file, default to 2 and 1, and no branch reaches them (Scenario#119.03)`
#### Scenario#119.04: neither pool's queue stands behind the other's
So that reviews do not push CI builds aside, and CI builds do not hold reviews back.
- **Given** the review pool is full and another review waits
- **and** the CI pool is full and another CI build waits
- **When** the running review ends
- **Then** the waiting review starts
- **and** the waiting CI build keeps waiting for a CI slot
- **And** a CI build queued while the review pool is full starts at once if a CI slot is free
##### Verified by
- [BuildExecutorUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorUnitTest.kt) `a full review pool leaves the CI its slot, and a full CI pool leaves the reviews theirs (Scenario#119.04)`
- [BuildSlotsUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildSlotsUnitTest.kt) `a full review pool leaves the CI its slots (Scenario#119.04)`
#### Scenario#119.05: the runs of one pull request run one after the other
So that an old head and a new head never share the fresh workspace of their pool at the same time.
- **Given** a review of pull request 7 at its old head is running
- **and** the per-repository limit of the review pool is 2
- **When** a review of pull request 7 at its new head is queued
- **and** a review of pull request 8 is queued
- **Then** the review of pull request 8 runs at once
- **and** the new head of pull request 7 waits until the old head's run ends
##### Verified by
- [BuildExecutorUnitTest](../../src/test/kotlin/de/hoennig/werkator/build/BuildExecutorUnitTest.kt) `the runs of one pull request, old head and new head, run one after the other (Scenario#119.05)`
#### Scenario#119.06: the views show the two pools apart
So that a reader sees which builds take the CI's slots and which the reviews'.
- **Given** a CI build and a review are running
- **When** the System page is shown
- **Then** the `Builds` tile counts the CI build against `maxConcurrent`
- **and** a `PR targets` tile counts the review against `maxConcurrentPullRequestTargets`, with the same colour rule
- **When** the Current Builds view is shown
- **Then** the review's card carries a `PR target` tag
##### Verified by
- [UiViewsUnitTest](../../src/test/kotlin/de/hoennig/werkator/server/UiViewsUnitTest.kt) `the pull-request targets have a tile of their own beside the builds tile (Scenario#119.06)`
- [SystemApiControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/SystemApiControllerRestTest.kt) `the system endpoint carries the executor's slots, for the builds tile (Scenario#101.03)`, `the running builds are listed with their repository (Scenario#27.05)`
- [BuildsApiControllerRestTest](../../src/test/kotlin/de/hoennig/werkator/server/BuildsApiControllerRestTest.kt) `current marks a pull-request target build as one of its own pool (Scenario#119.06)`
The `werkator.js` twins of the tile and the tag were not run in a browser; they follow the same data as the server-rendered page.
#### Scenario#119.07: export:forgejo serializes the runs of one pull request
So that a converted workflow keeps the one rule a workflow can say, and names the rest (ADR 0010).
- **Given** a definition `review` with `trigger.onPullRequestTarget: true`
- **When** it is exported with `export:forgejo`
- **Then** its workflow has `concurrency.group: pr-target-review-${{ github.event.pull_request.number }}` and `cancel-in-progress: false`
- **and** a note says that the pool's size belongs to the runners
- **and** a note says that a concurrency group keeps only the newest waiting run, where Werkator queues every head
##### Verified by
- [ForgejoWorkflowExporterUnitTest](../../src/test/kotlin/de/hoennig/werkator/forgejo/ForgejoWorkflowExporterUnitTest.kt) `the runs of one pull request wait for each other in a concurrency group of their own (Scenario#119.07)`, `a pull-request target build becomes a pull_request_target workflow on the base, skipping drafts (Scenario#117.08)`
## The Solution
`BuildPool` names the two pools and reads each pool's two limits from `ExecutorConfig`.
`BuildSlots` holds one set of fair semaphores per pool: a global one and one per repository.
Fair semaphores per pool are what gives each pool its own queue.
All four limits are sized once, from the first build, like before.
`RunningBuild.pool` is `PULL_REQUEST_TARGET` exactly when the build carries a pull request.
`BuildExecutor.admit` takes the slots of that pool.
The serial worker of a build is keyed by its lane: the branch for every build that uses the branch's worktree, `pull-request-target:<head-branch>@<build>` for a review.
The lane follows the fresh workspace, which is named after the pool name, so two heads of one pull request still run in order.
A `:` cannot occur in a branch name, so no branch shares a review's lane.
The views read the pool from `RunningBuild`: `CurrentBuildDto.pullRequestTarget`, `RunningBuildDto.pullRequestTarget` and `SystemDto.maxConcurrentPullRequestTargets`.
The System page renders a second slots tile; `werkator.js` splits `runningBuilds` by the flag the same way.
`export:forgejo` writes a workflow-level `concurrency` block for a pull-request target workflow.
The group carries the definition's name, because Werkator keeps two definitions for the same pull request apart.
## Open Questions
- Forgejo's support of `concurrency` depends on its version; the block is ignored by an older runner, and the notes say what differs.
- The `PR targets` tile shows on every instance, also on one without any pull-request target definition.
## Additional Changes
- `AGENTS.md` (hard invariant on `RepoContext` and the executor limits) and `.claude/skills/architecture/SKILL.md` describe the two pools.
- `docs/configuration.md`: both keys in the instance example, the key ownership list and the `executor` reference, and a paragraph "Concurrency" in the notes on `trigger.onPullRequestTarget`.
## Prerequisite PRs
- PR#117: `trigger.onPullRequestTarget`.
- PR#118: fresh workspace and home for pull-request target builds.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
mi
changed title from WIP: Pull-request target builds get their own concurrency pool to Pull-request target builds get their own concurrency pool, apart from the CI and their head branch (v1.37.0)2026-10-05 10:04:59 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The Problem
Since PR#117 a build with
trigger.onPullRequestTargetjudges a pull request, for example an AI review (werkrevisor).Such a review mostly waits for a language model: little CPU, little memory, about 10–70 s.
But it shared the limits of the CI:
executor.maxConcurrentPerRepository: 1made the review wait for the CI build of the same repository: about 4 min on mih09, 8–9 min on vm4006.The verdict should be there after about one minute, and it must not slow down the CI.
Non-Goals
The Scenarios
Feature: pull-request target builds run in a pool of their own
Background
trigger.onPullRequestTarget: true(PR#117).executor.maxConcurrentover all repositories andexecutor.maxConcurrentPerRepositoryper repository.executor.maxConcurrentPullRequestTargets(default 2) andexecutor.maxConcurrentPullRequestTargetsPerRepository(default 1).Scenario#119.01: a review runs beside the CI build of its repository and head branch
So that the verdict does not wait minutes for a CI build it does not need.
maxConcurrent: 1andmaxConcurrentPerRepository: 1feature/xis runningfeature/xis queuedfeature/x, since it runs in a fresh workspaceVerified by
a pull-request target build runs while the CI build of its repository and head branch runs (Scenario#119.01)a review runs beside the CI build of its own repository and head branch (Scenario#119.01)a review takes no CI slot and waits for none (Scenario#119.01)Scenario#119.02: the reviews of one repository wait for their own per-repository limit
So that one repository with many open pull requests cannot take every review slot.
maxConcurrentPullRequestTargetsPerRepository: 1andmaxConcurrentPullRequestTargets: 2Verified by
the reviews of one repository wait for their own per-repository limit (Scenario#119.02)a review waits for the other reviews of its repository (Scenario#119.02)Scenario#119.03: all reviews wait for their own global limit, which only the instance sets
So that the reviews of all repositories together stay within what the host and the model's quota allow.
maxConcurrentPullRequestTargets: 2in~/.werkator.yml.werkator.ymlcannot change themVerified by
the reviews of all repositories wait for their own global limit, the CI does not (Scenario#119.03)the review pool is sized from executor.maxConcurrentPullRequestTargets, apart from maxConcurrent (Scenario#119.03)the pull-request target limits come from the instance file, default to 2 and 1, and no branch reaches them (Scenario#119.03)Scenario#119.04: neither pool's queue stands behind the other's
So that reviews do not push CI builds aside, and CI builds do not hold reviews back.
Verified by
a full review pool leaves the CI its slot, and a full CI pool leaves the reviews theirs (Scenario#119.04)a full review pool leaves the CI its slots (Scenario#119.04)Scenario#119.05: the runs of one pull request run one after the other
So that an old head and a new head never share the fresh workspace of their pool at the same time.
Verified by
the runs of one pull request, old head and new head, run one after the other (Scenario#119.05)Scenario#119.06: the views show the two pools apart
So that a reader sees which builds take the CI's slots and which the reviews'.
Buildstile counts the CI build againstmaxConcurrentPR targetstile counts the review againstmaxConcurrentPullRequestTargets, with the same colour rulePR targettagVerified by
the pull-request targets have a tile of their own beside the builds tile (Scenario#119.06)the system endpoint carries the executor's slots, for the builds tile (Scenario#101.03),the running builds are listed with their repository (Scenario#27.05)current marks a pull-request target build as one of its own pool (Scenario#119.06)The
werkator.jstwins of the tile and the tag were not run in a browser; they follow the same data as the server-rendered page.Scenario#119.07: export:forgejo serializes the runs of one pull request
So that a converted workflow keeps the one rule a workflow can say, and names the rest (ADR 0010).
reviewwithtrigger.onPullRequestTarget: trueexport:forgejoconcurrency.group: pr-target-review-${{ github.event.pull_request.number }}andcancel-in-progress: falseVerified by
the runs of one pull request wait for each other in a concurrency group of their own (Scenario#119.07),a pull-request target build becomes a pull_request_target workflow on the base, skipping drafts (Scenario#117.08)The Solution
BuildPoolnames the two pools and reads each pool's two limits fromExecutorConfig.BuildSlotsholds one set of fair semaphores per pool: a global one and one per repository.Fair semaphores per pool are what gives each pool its own queue.
All four limits are sized once, from the first build, like before.
RunningBuild.poolisPULL_REQUEST_TARGETexactly when the build carries a pull request.BuildExecutor.admittakes the slots of that pool.The serial worker of a build is keyed by its lane: the branch for every build that uses the branch's worktree,
pull-request-target:<head-branch>@<build>for a review.The lane follows the fresh workspace, which is named after the pool name, so two heads of one pull request still run in order.
A
:cannot occur in a branch name, so no branch shares a review's lane.The views read the pool from
RunningBuild:CurrentBuildDto.pullRequestTarget,RunningBuildDto.pullRequestTargetandSystemDto.maxConcurrentPullRequestTargets.The System page renders a second slots tile;
werkator.jssplitsrunningBuildsby the flag the same way.export:forgejowrites a workflow-levelconcurrencyblock for a pull-request target workflow.The group carries the definition's name, because Werkator keeps two definitions for the same pull request apart.
Open Questions
concurrencydepends on its version; the block is ignored by an older runner, and the notes say what differs.PR targetstile shows on every instance, also on one without any pull-request target definition.Additional Changes
AGENTS.md(hard invariant onRepoContextand the executor limits) and.claude/skills/architecture/SKILL.mddescribe the two pools.docs/configuration.md: both keys in the instance example, the key ownership list and theexecutorreference, and a paragraph "Concurrency" in the notes ontrigger.onPullRequestTarget.Prerequisite PRs
trigger.onPullRequestTarget.🤖 Generated with Claude Code
WIP: Pull-request target builds get their own concurrency poolto Pull-request target builds get their own concurrency pool, apart from the CI and their head branch (v1.37.0)