Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 4: one repository may be bounded, and a freed slot wakes the parked

8d33608319bb83725256d02196925309fe521cda · Keenan Nemetz · 16d ago · parent 379dad1e

Phase 4: one repository may be bounded, and a freed slot wakes the parked

max_jobs_per_repo caps how many of one repository's jobs run at once; unset
there is still no cap. Ordering and bounding answer different questions: the
lease already serves the least busy repository first, which stops a burst
taking the whole fleet, but ordering is not a bound — and what an operator
wants when one repository's build saturates the instance is a number.

A repository at its cap is skipped rather than blocking, so the other
repositories an account may run are still served. That makes nil from the
lease mean two things: "nothing to do", and "work exists, but not for you
yet". The second meaning is why Finish now wakes the parked lease — releasing
a slot is work somebody can pick up, and without it a runner parked on a cap
would sit out its whole wait against a queue it could already have taken.
Both are one line of the same query: `? = 0 OR COALESCE(busy.n, 0) < ?`, so
"no cap" does not become a second query string that can drift.

The runners page renders `4 of 4 (at the cap)` beside the depth, because a
queue that is full and not draining is exactly the question that page exists
to answer: "7 waiting, 4 running" is a mystery where the cap is not shown.

Measured: TestPerRepoCapBoundsOneRepositoryAndResumesWhenASlotFrees puts two
jobs in each of two repositories with a cap of 1, so "nothing to lease"
cannot be confused with "no work" — both repositories served once, a third
runner refused, a finish waking the parked lease and the freed slot's own
repository becoming leasable. TestRunnersPageSaysWhenARepositoryIsAtItsCap
and TestMaxJobsPerRepoLoads cover the page and the key.

docs/roadmap.md +20−5

@@ -516,16 +516,30 @@ sits inside a signed record and is routinely wrong about the thing a reader woul
516 516 it describes is worse than no number. OS and architecture survive the same objection
517 517 because they describe the machine, which is the fact this slice was actually for.
518 518
519 ✅ **Slice 10 — one repository may be bounded.** `max_jobs_per_repo` caps how many of
520 one repository's jobs run at once; unset, there is still no cap. The distinction that
521 made this worth a knob: the lease already *orders* by in-flight count, so a burst cannot
522 take the whole fleet — but ordering is not a bound, and what an operator wants when one
523 repository's build saturates the instance is a number. A repository at its cap is
524 *skipped*, not blocking, so the other repositories an account may run are still served
525 and `nil` from the lease now means "work exists, but not for you yet". That second
526 meaning is why `Finish` wakes the parked lease: releasing a slot is work somebody can
527 pick up, and without the wake-up a runner parked on a cap would sit out its whole wait
528 against a queue it could already have. The runners page says `4 of 4 (at the cap)`
529 beside the depth, because a queue that is full and not draining is exactly the question
530 that page exists to answer.
531 (Measured: `TestPerRepoCapBoundsOneRepositoryAndResumesWhenASlotFrees` — two jobs in
532 each of two repositories, cap 1, so "nothing to lease" cannot be confused with "no
533 work": both repositories served once, a third runner refused, a finish wakes the
534 parked lease and the freed slot's own repository is leasable; and
535 `TestRunnersPageSaysWhenARepositoryIsAtItsCap`.)
536
519 537 Remaining:
520 538
521 539 - A narrowing filter on the runner (`--repos`, labels) and job-level targeting
522 540 (`runs-on`): one instance-wide pool is right for identical machines and wrong for one
523 541 macOS runner in a Linux pool. The filter can only narrow what the allowlist grants,
524 542 never widen it
525 - No per-repository in-flight cap. Fairness is ordering — least busy repository first —
526 which stops a burst taking the whole fleet without a knob to tune; a cap is what an
527 operator wants when they need to *bound* one repository's footprint, and that is a
528 config key to add when somebody asks for the bound rather than the ordering
529 543
530 544 **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in
531 545 the job being requeued and completing on another runner ✅; a workflow that mutates its
@@ -1023,9 +1037,10 @@ Filled in as phases land. Append; don't rewrite.
1023 1037 | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise |
1024 1038 | 2026-10-23 | The queue is reconciled from the refs, not persisted as a push-event log | The roadmap item asked for push events in SQLite, and a row there would not have been atomic with the ref update either: the hook is another process writing another file, so it shrinks the crash window instead of closing it, puts a database write into the path that must never fail a push, and dies with the same `rm data/runs.db` it was meant to survive. A ref tip is already the durable statement that a commit is live, so `ref_cursors` plus a 30-second pass answers the same question without a second copy of the fact — and the spool becomes a latency hint. The cost, accepted and written down: an emptied queue or a first start after an upgrade queues each current tip once |
1025 1039 | 2026-10-23 | An idle lease parks on an in-process wake-up channel, and eligibility is cached for one poll window | The "long poll" was the client's own timer — the queue answered at once and the runner slept — and every ask re-derived the account's repositories by opening each one's rules, which is O(repos) per idle runner every two seconds. The wait is a hint and never a durability mechanism (the lease is still the database's decision), so a missed wake-up costs one poll interval; in-process is enough because one `vole serve` owns the queue, and a second process would have to ask the database anyway |
1026 | 2026-10-23 | A new tip cancels the queued runs of the same ref, and the lease serves the least busy repository first | Pushing twice in a minute built the first commit twice while `canceled` existed with no writer. The cancellation covers queued rows only: interrupting a running build is a different promise from not starting one. Fairness is ordering rather than a per-repository cap, because a cap is a number somebody has to tune and ordering needs none — a burst still runs, it just cannot take every runner |
1040 | 2026-10-23 | A new tip cancels the queued runs of the same ref, and the lease serves the least busy repository first (partly superseded 2026-10-23: ordering is kept for fairness, and the cap is added beside it as `max_jobs_per_repo`) | Pushing twice in a minute built the first commit twice while `canceled` existed with no writer. The cancellation covers queued rows only: interrupting a running build is a different promise from not starting one. Fairness is ordering rather than a per-repository cap, because a cap is a number somebody has to tune and ordering needs none — a burst still runs, it just cannot take every runner |
1027 1041 | 2026-10-23 | Runner presence is a queue row per channel, filtered by the clone rule | A runner was an SSH channel invisible to both the web and the queue, so "is my build waiting because nobody is listening" had no answer anywhere. Presence is derived rather than stored: written by connect, lease, heartbeat and finish, dropped when the channel closes, aged out after ten silent minutes. The web is anonymous, so a session appears only while the repository it is building is public — naming a private repository would confirm it exists |
1028 1042 | 2026-10-23 | A record names the machine (OS, architecture), not the toolchain | The roadmap asked for the runner's Go version too, and it is the one field here that would mislead: `runtime.Version()` reports what compiled the *runner binary*, not what a job builds with, so in a signed record it would be a confident answer to a question nobody asked. OS and architecture describe the machine, which is the fact worth keeping — it is what turns "one macOS runner in a Linux pool" into something a finished run can answer. Both are the binary's build target rather than a live probe: a `uname` subprocess would add a failure path on Windows to sharpen a fact the binary already knows, and the only case it would correct is emulation. The keys are optional so records written before them still read, since a record is never rewritten |
1043 | 2026-10-23 | A repository may be bounded with `max_jobs_per_repo`, and finishing a job wakes the lease | Ordering and bounding answer different questions: ordering (least busy repository first) keeps one repository's burst from taking the whole fleet, and a cap is what bounds one repository's footprint on purpose. Enforcing it as a skip in the lease rather than a refusal keeps the other repositories an account may run serviceable, which makes `nil` mean two things — "nothing to do" and "work, but not for you yet" — and a cap-bound queue is exactly where the second meaning would hang forever, since a parked runner has nothing to wake it. So `Finish` notifies as well: a released slot is leasable work. The runners page renders `4 of 4 (at the cap)` for the same reason, because a depth that is full and not draining with no explanation is the question that page exists to answer |
1029 1044
1030 1045 ## Open questions
1031 1046