Skip to content
Like what we’re building? Star on GitHub

Board dispatch ​

A board card becomes a run when one dispatcher tick walks it through a gauntlet of guards, claims it under a fenced lease, hands its bot to a runner, and settles the card on exactly one of five dispositions.

Drawn from the code at commit f564846d3. How this page is kept true: README.

The machine exists twice with one semantics: a local daemon (pkg/dispatcher) whose single actor goroutine owns all mutable state, and a cloud twin (pkg/server/boarddispatch.go over pkg/dispatcher/boardmongo) that runs per replica with no leader election. The tick below is the local one; the cloud twin names every point where the same decision is taken differently. The run's own status machine belongs to data-model-runs.md and is not redrawn here — a run status and a claim state are two of the five vocabularies the glossary keeps apart.

One tick, from candidate to disposition ​

The three filing dispositions — COMPLETED, GAVE UP, REVERT — are executed by an off-actor finish worker that does the transition first and Release last, stopping the heartbeat after its own last write: the claim is what keeps ListCandidates away until the card sits in its final, mostly non-eligible column. PARKED and HELD never reach that worker: the actor itself stamps last_run, stops the heartbeat and keeps the claim — no Release at all, which is the parking brake of ADR-014. PARKED also moves the card into awaiting_input on the actor, best-effort; HELD leaves it in its column with a visible skip.

actoractioncode
operatorboots the daemon — no argument takes the baked-in preset, an argument loads the YAMLcli.RunDispatch, BuildDefaultConfig, dispatcher.Load
servertakes the exclusive lock on the workspace root, persists the config, startsstore.AcquireFileLock, Manager.SaveConfig, Manager.Start
serverstarts the actor, the boot claim sweeps and the gated watchdogDispatcher.Start, sweepJournalledClaims, startClaimReaper
serverthe four reconcilers, then the tick's own early returnstick, reconcileStalled, refreshRunningStates, refreshCostCap
servercloses the lifecycle of cards answered out of band, and re-parks a paused last_run after a rebootreconcileParked, reconcileStrandedPaused
forgediscovery, off the actor: unclaimed cards in eligible columns whose hard blockers are done, or open issues by labelAdapter.ListCandidates, BlockersSatisfiedForIssue, the github, forgejo and gitlab adapters
serversorts, prunes stale skips, walks against the global and per-state capssortCandidates, cmdCandidates.apply, hasSlot
serverroutes a fully unassigned card first, and steps aside when a pull request already links itrouteUnassignedIssue, linkedPRProbe
serverrefuses a card naming a bot it cannot resolve or route, instead of running the default workflowresolveExplicitBot, botregistry.ResolveBotPath, HasRoute
storethe durable last_run holds, resolved BEFORE the claimlastRunHoldBeforeClaim, promoteIfOrphaned, resumeGenerationState
storejournals the claim, then takes it — fenced where the backend carries a leaseclaimJournal.Record, Tracker.Claim, ClaimLeaser.ClaimLease
serverre-parks a card whose last_run is a dispatcher-owned pause — claim kept, dispatch abandonedreparkClaimedIfLastRunWaiting, reparkToAwaitingInput
serverresume versus fresh, and the workspace generation with its ownership markerresolveRunID, dispatchWorkspaceLifecycle, workspaces.CreateForRun
serverstarts the lease heartbeat only once the workspace is ours, and reports a supersession onceStartClaimSession, claimSession.loop, cmdClaimLost
serverthe in-progress move and the mkdir, off the actorrunDispatchSetup, fencedUpdateState, cmdDispatchSetupDone
runnerthe hooks and the dispatch, on the worker goroutinerunWorker, Hook.Run, Runner.Dispatch
serverafter_run on every return of the dispatch, then teardown on a clean return only — both on the worker goroutine, before the claim is releasedthe after_run hook, cleanupWorkspace, the before_remove hook
serverthe one choke point of the retry policy — five dispositions from one errorfinishRun, exhausted, retrypolicy.IsDeterministic
storethe three filing dispositions: the chosen transition first, Release last, then the heartbeat stopsrunFinishWorker, maybeTransitionToCompleted, revertTransition, stampGiveUp
serverPARKED and HELD, settled on the actor — claim kept, heartbeat stopped, no ReleasemoveToAwaitingInput, stopClaimSession, recordDispatchSkip
serverthe retry queue — per-issue timer, accumulating attempt, resume pointerscheduleRetry, computeBackoff, sandboxBackoff, cmdRetryDue
serverthe watchdog: transfer the expired claim, re-decide with ownership, file, repark or releasereapExpiredClaims, reapOne, ClaimReaper.ReclaimExpired, DecideStuckCard

The claim ​

The claim's own state machine lives with the feature: dispatcher.md — State machine.

statewhat it meanscode
unclaimedclaim empty or absent — the Mongo CAS reads an absent field as unclaimedStore.Claim, Coordinator.Claim
claimedthe holder owns the card: claim, a bumped claim_epoch, claimed_at, claim_lease_untilnative.ClaimLeaseDuration (15 min), Store.ownedFilter
supersededthe fence refuses this holder's writes — every later move and the release includedtracker.ErrClaimConflict, claimSession.Lost
retaineda deliberate long-term hold: the parking brake of a paused run, or an operator cancelfinishRun, stopClaimSession, ADR-014
recoverya watchdog holds the card under its own marker while it decides and filesReclaimExpired, ReaperMarkerPrefix, keepAfterTransfer

The watchdog never clears a claim before deciding: it CAS-transfers it, then re-takes the decision on the state it observed with ownership. One pure table serves both twins — a read error, a live run, any pause, an operator cancel and an armed continuation all conserve; a gone-but-recorded run is filed with a give-up stamp rather than re-dispatched. It is off unless ITERION_BOARD_CLAIM_REAPER is on, and a tracker without both ClaimLeaser and ClaimReaper logs one warning and stays off.

The cloud twin ​

the same decisionlocal daemoncloud twin
who may dispatchone daemon per workspace root, held by a flockevery replica, every 5 s — the per-card Claim CAS elects one
candidatesevery eligible column — ready and in_progress on the default board — unclaimed, hard blockers doneListDispatchable — unclaimed cards in ready only (the tick's eligible set is that one column), carrying a bot, past their ledger not_before; oldest-updated first, capped at twice the concurrency. No blocker term in the query, and no per-card blocker gate anywhere on this path
admissionresolveExplicitBot, before the claimadmitBoardCard, before the claim; then gateLaunch as the synthetic board-dispatcher identity on the card's team
the runrunner.Dispatch in a per-issue workspaceruns.Launch onto the queue, then a 3 s poll to terminal
last_run on the cardstamped by finishRun, when the run endsstamped right after the launch, so the studio links the LIVE run
a pauseparked in awaiting_input, claim retainedmoved to awaiting_input and released — sweepParked closes it later
a refused launchthe ordinary retry ladderthe launch-refusal ledger: 1m, 2m, 4m … capped at 30m, filed blocked with a give-up stamp at the attempt cap (default 8)
a drain mid-runthe force-reap arm revertsthe card is left in place and only the claim is released — the run keeps executing on its pod

Guards that refuse ​

  • paused — the manager's desired state: the sweeps still run, retries and stall detection still fire, nothing new is dispatched.
  • the daily spend cap (limits.max_cost_per_day_usd) — the tick returns, and the gate is re-checked when the off-actor discovery lands.
  • agent.max_concurrent — breaks the walk, and short-circuits the tick before the tracker call; agent.max_concurrent_by_state only skips that candidate, because another column may have room.
  • a linked pull request — the ticket router runs before the bot guard, on a card with neither bot nor assignee: a linked PR means the inbound PR webhook already owns the work, so the dispatcher steps aside with a visible skip and a bot:billy label rather than starting a second, branch-less run. A tracker that cannot answer the probe routes to the implement bot instead.
  • bot … can't be resolved / no active dispatch route — an operator-visible skip, no claim: running the default workflow would report a misleading success.
  • the last_run holds — refusing a fresh sibling for a running, queued or foreign-paused run; cancelled by the operator, which names --clear-last-run as the way out; an unreadable run or an unopenable store holds too, fail-closed.
  • tracker.ErrClaimConflict on the claim — the journal entry is removed and no slot is allocated.
  • a dispatcher-owned paused last_run, refused after the claim — reparkClaimedIfLastRunWaiting moves the card back to awaiting-input (or records a skip when the board has no such column), keeps the claim and abandons the dispatch: the claim is what makes the re-park atomic against another dispatcher, and in_progress being eligible is why a fresh sibling would otherwise be minted from the workflow entry.
  • tracker.ErrClaimConflict on the in-progress move — the one error that inverts the best-effort rule: claim lost before launch, the run is never started. ErrTransitionRejected and ErrNotSupported continue.
  • tracker.ErrClaimConflict on a renewal — cmdClaimLost cancels only the run that owns the card now, with ErrRunInterrupted; a transient store error retries at the next beat, and the owner's own release is latched as such, never as a supersession.
  • the workspace ownership probe — the claim is released and the dispatch deferred.
  • errCardUnlaunchable (cloud) — no run service, no bot on the card, an unresolvable bot source or unparseable reserved bot-args: skipped in its column, never claimed, never blocked.
  • gateLaunch denials (cloud) — org_suspended, concurrency_cap_exceeded, launch_rate_limited, monthly_run_quota_exceeded: a launch refusal, so the card goes back under machine provenance with the rule on its ledger.
  • ShouldFileStuckCard — the watchdog does not overwrite a deliberate move, and keeps the recovery claim when its filing did not land.
  • failed_state: none — an explicit opt-out: with no terminal column to move a doomed card into, the bounded ladder is the honest fallback and the give-up arm is never taken.
  • the workspace flock — a second daemon on the same workspace root is refused outright.

Collections touched ​

collectionwritten bywhat
board_issuesboth twinsthe card: its column, claim / claim_epoch / claimed_at / claim_lease_until, last_run_id, last_workdir, the awaiting-input badge, the launch-refusal ledger and the give-up stamp
board_configcloud twinthe board schema the eligible and terminal columns come from (read), and the per-tenant event-seq counter seq:<tenant> that every emitted board event $inc-upserts: claim, in-progress move, last_run stamp, final move, release, launch refusal, give-up
board_eventsboth twinsone event per content change — a lease renewal is liveness, so it appends nothing
trigger_effectsserverthe durable projection row a native card move materializes for the bound forge board
forge_board_bindingssync workerthe due list, the per-tenant sync lease and the degraded reason
runs, eventsengine, runnerread by every hold, sweep and watchdog decision; written by the run itself

Without Mongo the same documents are files under <store>/dispatcher/: board.json, issues/<id>.json, events.jsonl, the claim journal claims.json and the operator's intent in runtime.json; the workspaces and the single-instance lock live under <workspace.root>. That twin has no counter document — it recovers the next event seq from the highest one in events.jsonl when it opens the store.