> 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)
---------
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #119
11 KiB
PR#119: Pull-Request Target Builds Get Their Own Concurrency Pool
WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. 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: 1made 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.maxConcurrentover all repositories andexecutor.maxConcurrentPerRepositoryper repository. - The review pool is
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.
- Given
maxConcurrent: 1andmaxConcurrentPerRepository: 1- and a CI build of branch
feature/xis running
- and a CI build of branch
- When a review of a pull request with head
feature/xis 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
- and it is not serialized with the builds of
Verified by
- BuildExecutorIntegrationTest
a pull-request target build runs while the CI build of its repository and head branch runs (Scenario#119.01) - BuildExecutorUnitTest
a review runs beside the CI build of its own repository and head branch (Scenario#119.01) - BuildSlotsUnitTest
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: 1andmaxConcurrentPullRequestTargets: 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
the reviews of one repository wait for their own per-repository limit (Scenario#119.02) - BuildExecutorUnitTest
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: 2in~/.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.ymlcannot change them
Verified by
- BuildSlotsUnitTest
the reviews of all repositories wait for their own global limit, the CI does not (Scenario#119.03) - BuildExecutorUnitTest
the review pool is sized from executor.maxConcurrentPullRequestTargets, apart from maxConcurrent (Scenario#119.03) - ConfigLoaderUnitTest
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
a full review pool leaves the CI its slot, and a full CI pool leaves the reviews theirs (Scenario#119.04) - BuildSlotsUnitTest
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
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
Buildstile counts the CI build againstmaxConcurrent- and a
PR targetstile counts the review againstmaxConcurrentPullRequestTargets, with the same colour rule
- and a
- When the Current Builds view is shown
- Then the review's card carries a
PR targettag
Verified by
- UiViewsUnitTest
the pull-request targets have a tile of their own beside the builds tile (Scenario#119.06) - SystemApiControllerRestTest
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
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
reviewwithtrigger.onPullRequestTarget: true - When it is exported with
export:forgejo - Then its workflow has
concurrency.group: pr-target-review-${{ github.event.pull_request.number }}andcancel-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
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
concurrencydepends on its version; the block is ignored by an older runner, and the notes say what differs. - The
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
- PR#117:
trigger.onPullRequestTarget. - PR#118: fresh workspace and home for pull-request target builds.
🤖 Generated with Claude Code