Auto-pick (online runner)
Easy, well-specified issues can be built, tested and merged without a developer at the keyboard. Spexso proposes an auto-pick label on issues that pass a rubric and a human confirms it. An online runner (GitLab CI) then picks the labeled issues on a schedule and whenever a label event arrives, runs the existing marathon on them, and queues each MR for GitLab auto-merge. The pipeline is the merge gate — the agent never merges itself.
Auto-pick is opt-in per project and off by default. While it is off, /spexso-brainstorm does not propose the label, npx spexso triage refuses to run, and the runner exits before it starts Claude.
Enabling
npx spexso autopick enable # writes autopick.enabled: true to .spexso/config.json
npx spexso autopick disable/spexso-setup offers the same switch as a question (default no). Either way the change lands in .spexso/config.json, and the runner reads that file from your target branch — so auto-pick only starts once the config change is committed and merged. Enabling is a visible, reviewable act.
Emergency stop: pause the pipeline schedule and the trigger in GitLab. There is no extra mechanism.
Which issues
An issue is eligible when all of these hold:
| Rule | Default |
|---|---|
Size label is one of autopick.rubric.sizes | xs, s |
Type label is one of autopick.rubric.types | bug, enhancement, tech-debt, maintenance |
The spec has an Acceptance criteria section with at least one item (requireAcceptanceCriteria); the section runs to the next heading of its own level or higher, so ### groups inside it count | on |
| No open blockers | fixed, not configurable |
The spec mentions no parallel.hotspots path and no rubric.excludeTerms entry (case-insensitive substring) | auth, security, xs-security, migration, mta.yaml |
Override only the keys that do not fit your project — the rubric is merged key by key with the defaults:
{"autopick": {"rubric": {"sizes": ["xs"]}}}The rubric only gates the proposal. A human always confirms the label:
/spexso-brainstormasks "This issue qualifies for auto-pick. Add the label?" once the size is estimated and the rubric passes.npx spexso triagegoes over thestage::specissues that have no label and no assignee, asks y/n for each eligible one, and lists the others with the rule they failed — they are never labeled.npx spexso autopick eligible <id>prints the verdict and reasons for one issue as JSON.
Setting up the runner project
The runner lives in its own GitLab project (for example flexso/spexso-autopick), never in a product: a product's main pipeline would run its whole CI on every auto-pick run.
- Create the project and copy
scaffold/autopick/gitlab-ci.yml.template(shipped in the@flexso/spexsopackage) to its.gitlab-ci.yml. It holds one job that only runs onscheduleandtriggerpipelines that setTARGET_PROJECT. - Create a bot user and add it as Developer to every product that enables auto-pick, and make sure it is allowed to merge into the product's
targetBranch(Settings → Repository → Protected branches → "Allowed to merge"). Commits, MRs and assignments are then attributable to the bot, and MRs carryai::ai-generated. - In every product, enable "Pipelines must succeed" (Settings → Merge requests). That setting is what makes the pipeline the merge gate: without it, auto-merge does not wait for a green pipeline.
- Create a token for the bot with the scopes
api,write_repositoryandread_api(read_apiletsnpm ciresolve@flexso/spexsothrough the product's committed.npmrc— the job passes the token asNPM_REGISTRY_READ_TOKEN, see Onboarding). - Set these CI variables on the runner project only:
ANTHROPIC_API_KEYandSPEXSO_BOT_TOKEN(masked),SPEXSO_BOT_NAMEandSPEXSO_BOT_EMAIL(plain — the commit identity). - Add one pipeline schedule per product (Build → Pipeline schedules) with the variable
TARGET_PROJECT=<group/product>. When the product'stargetBranchis not its default branch, also setTARGET_BRANCH=<targetBranch>: the job clones that branch, so the config and the base come from it. - For immediate pickup, create a pipeline trigger token in the runner project (Settings → CI/CD → Pipeline trigger tokens) and add an issue-events webhook in the product (Settings → Webhooks) to
https://<gitlab>/api/v4/projects/<runner-project-id>/trigger/pipeline?token=<trigger token>&ref=main&variables[TARGET_PROJECT]=<group/product>(plus&variables[TARGET_BRANCH]=<targetBranch>when needed). The job does not parse the payload — the preflight decides whether there is work. - Keep the product's Spexso commands and agents current: the runner uses the ones committed in the product, so after a Spexso upgrade run
npx spexso updatethere and commit the result, or the runner runs stale skills. - Optionally register Hetzner machines as GitLab runners for this project. Nothing else needs installing.
Each job keeps .spexso/marathon-log.jsonl and .spexso/observability/ as job artifacts for 14 days (also when the job fails) — that is where the marathon log of a run lives.
The runner supports GitLab only: with another code provider in .spexso/config.json the run fails fast, and it also fails before touching any issue when the bot identity cannot be resolved (whoami returns nothing).
The job uses resource_group: autopick-$TARGET_PROJECT, so two runs never touch the same product at once (different products run in parallel), and a 2 hour timeout.
What a run does
npx spexso autopick runs three steps:
- Preflight (no tokens). If auto-pick is disabled, it exits. Otherwise it runs the sweep below, then looks for an issue that has the
auto-picklabel, hasstage::specas its only stage label, has noneeds::*label, is unassigned and has no open blockers. Nothing ready → exit with "nothing to pick". Checking blockers here matters: every issue event triggers a run, and a blocked issue would otherwise start an empty Claude session each time. - Work. It starts
claude -p "/spexso-marathon --autopick --max {maxPerRun}"with--permission-mode acceptEditsand--max-budget-usd {maxBudgetUsd}, under the project's installed permissions. - Queue. In
--autopickmode the marathon only takesauto-pickissues, runs them sequentially, and the merge train ends with/spexso-finish --train-merge --autopick: rebase, full-suite gate, the MR pipeline as the second half of the gate (with repair rounds while they make progress), an ownership re-check, thenpr merge --auto-merge. GitLab merges when the MR pipeline is green. The outcome isqueued(ortaken_over/not_queued, see below). The bot never commits to your target branch directly.
Auto-pick runs skip the CHANGELOG entry, the spec archive, the learning card and the demo note: finish steps 10–11 belong to the human flow. The squash-merge commit message is the record of the change.
The sweep
Auto-pick never retries on its own. Every failure ends in the same hand-off: auto-pick is removed, needs::attention is added and a note explains why. A human re-adding the label is the only retry. The sweep runs at the start of every run and only touches issues assigned to the bot that still carry the label:
| Situation | Result |
|---|---|
| 3 strikes, task budget | needs::attention + note (marathon); auto-pick stays on and the next sweep hands it off |
| Rebase conflict during the train | needs::conflict-resolution (finish); auto-pick stays on and the next sweep hands it off |
| Full suite red after the rebase | needs::attention + note (finish); auto-pick stays on and the next sweep hands it off |
pr merge --auto-merge keeps failing (no pipeline for the pushed commit, or the bot may not merge into targetBranch) | hand-off right in finish (not_queued) |
| MR has merge conflicts while queued | hand-off + needs::conflict-resolution |
MR pipeline failed or canceled | hand-off, MR stays open |
| MR still open, queued for auto-merge or pipeline still running | left alone (waiting) |
| MR open, not queued for auto-merge and no pipeline running | hand-off (not_queued) |
| MR merged | manual-test checklist posted (mentioning the tester when the test hand-off picks one), stage::ship, then one last update: tester assigned, needs::manual-test plus handoff.labels added, auto-pick and handoff.unlabel removed (with handoff.on: "deploy" no tester or hand-off labels yet — the handoff-dev CI job does that after the DEV deploy). A failure before that update leaves the issue on the bot with the label, so the next sweep tries again |
| No open or merged MR (job crashed mid-run, or the MR was closed) | bot unassigned + hand-off in one update |
| The sweep itself fails on an issue (e.g. an API error) | error logged for that issue, the label stays, the other issues are still swept |
The hand-off note tells the developer how to retry: close any open MR, delete branch spexso/issue-{id}, unassign, move the issue back to stage::spec (npx spexso stage {id} spec), then re-add the label.
Bot and developers
The bot is its own GitLab user, so the assignment lock works both ways: the marathon skips issues assigned to a developer, and /spexso-start blocks on an issue assigned to the bot.
- A manual start wins. When a human runs
/spexso-starton anauto-pickissue, the label is dropped so the issue leaves the bot's queue. To hand it back to the bot: unassign it, move it back withnpx spexso stage {id} spec, and re-add the label. - Atomic branch claim. On the runner path
/spexso-start --autopickclaimsspexso/issue-{id}with a compare-and-swap push that only succeeds if the branch does not exist yet. A rejected claim is a hand-off: the bot unassigns itself, removesauto-pick, addsneeds::attentionand notes that the branch already exists — so the issue is not picked again on the next run. To retry, delete the branch and re-add the label. An existing remote branch is always a skip for the bot (a developer only gets a warning). - Ownership re-check. Right before the push and auto-merge, the bot re-reads the assignee. If it is no longer the bot, it removes
auto-pick, posts "taken over by @user, auto-pick stopped", and leaves the MR open without auto-merge.
Known ceiling
A developer who starts an issue just after the bot's branch claim but before its assignment only gets the existing "branch exists" warning. If you see this in practice, the same claim can be added to the manual /spexso-start.
Costs and limits
| Knob | Default | Effect |
|---|---|---|
autopick.maxPerRun | 3 | Issues per run (--max) |
autopick.maxBudgetUsd | 10 | Hard spend cap per run (--max-budget-usd) |
Job timeout | 2h | Kills a runaway run |
modelBySize | xs/s → Sonnet | Tester and builder use the cheaper model on small issues |
A run with nothing to pick spends no tokens: the preflight and sweep are plain code.
Known ceiling
Issues queued in the same run were each rebased onto a target branch that did not yet contain the other queued MRs. A semantic conflict between them is caught by the main pipeline after the merge, not before. Set maxPerRun to 1, or use GitLab merge trains / merged-results pipelines, if that matters for your product.