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.
| actor | action | code |
|---|---|---|
| operator | boots the daemon — no argument takes the baked-in preset, an argument loads the YAML | cli.RunDispatch, BuildDefaultConfig, dispatcher.Load |
| server | takes the exclusive lock on the workspace root, persists the config, starts | store.AcquireFileLock, Manager.SaveConfig, Manager.Start |
| server | starts the actor, the boot claim sweeps and the gated watchdog | Dispatcher.Start, sweepJournalledClaims, startClaimReaper |
| server | the four reconcilers, then the tick's own early returns | tick, reconcileStalled, refreshRunningStates, refreshCostCap |
| server | closes the lifecycle of cards answered out of band, and re-parks a paused last_run after a reboot | reconcileParked, reconcileStrandedPaused |
| forge | discovery, off the actor: unclaimed cards in eligible columns whose hard blockers are done, or open issues by label | Adapter.ListCandidates, BlockersSatisfiedForIssue, the github, forgejo and gitlab adapters |
| server | sorts, prunes stale skips, walks against the global and per-state caps | sortCandidates, cmdCandidates.apply, hasSlot |
| server | routes a fully unassigned card first, and steps aside when a pull request already links it | routeUnassignedIssue, linkedPRProbe |
| server | refuses a card naming a bot it cannot resolve or route, instead of running the default workflow | resolveExplicitBot, botregistry.ResolveBotPath, HasRoute |
| store | the durable last_run holds, resolved BEFORE the claim | lastRunHoldBeforeClaim, promoteIfOrphaned, resumeGenerationState |
| store | journals the claim, then takes it — fenced where the backend carries a lease | claimJournal.Record, Tracker.Claim, ClaimLeaser.ClaimLease |
| server | re-parks a card whose last_run is a dispatcher-owned pause — claim kept, dispatch abandoned | reparkClaimedIfLastRunWaiting, reparkToAwaitingInput |
| server | resume versus fresh, and the workspace generation with its ownership marker | resolveRunID, dispatchWorkspaceLifecycle, workspaces.CreateForRun |
| server | starts the lease heartbeat only once the workspace is ours, and reports a supersession once | StartClaimSession, claimSession.loop, cmdClaimLost |
| server | the in-progress move and the mkdir, off the actor | runDispatchSetup, fencedUpdateState, cmdDispatchSetupDone |
| runner | the hooks and the dispatch, on the worker goroutine | runWorker, Hook.Run, Runner.Dispatch |
| server | after_run on every return of the dispatch, then teardown on a clean return only — both on the worker goroutine, before the claim is released | the after_run hook, cleanupWorkspace, the before_remove hook |
| server | the one choke point of the retry policy — five dispositions from one error | finishRun, exhausted, retrypolicy.IsDeterministic |
| store | the three filing dispositions: the chosen transition first, Release last, then the heartbeat stops | runFinishWorker, maybeTransitionToCompleted, revertTransition, stampGiveUp |
| server | PARKED and HELD, settled on the actor — claim kept, heartbeat stopped, no Release | moveToAwaitingInput, stopClaimSession, recordDispatchSkip |
| server | the retry queue — per-issue timer, accumulating attempt, resume pointer | scheduleRetry, computeBackoff, sandboxBackoff, cmdRetryDue |
| server | the watchdog: transfer the expired claim, re-decide with ownership, file, repark or release | reapExpiredClaims, reapOne, ClaimReaper.ReclaimExpired, DecideStuckCard |
The claim
The claim's own state machine lives with the feature: dispatcher.md — State machine.
| state | what it means | code |
|---|---|---|
unclaimed | claim empty or absent — the Mongo CAS reads an absent field as unclaimed | Store.Claim, Coordinator.Claim |
claimed | the holder owns the card: claim, a bumped claim_epoch, claimed_at, claim_lease_until | native.ClaimLeaseDuration (15 min), Store.ownedFilter |
superseded | the fence refuses this holder's writes — every later move and the release included | tracker.ErrClaimConflict, claimSession.Lost |
retained | a deliberate long-term hold: the parking brake of a paused run, or an operator cancel | finishRun, stopClaimSession, ADR-014 |
recovery | a watchdog holds the card under its own marker while it decides and files | ReclaimExpired, 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 decision | local daemon | cloud twin |
|---|---|---|
| who may dispatch | one daemon per workspace root, held by a flock | every replica, every 5 s — the per-card Claim CAS elects one |
| candidates | every eligible column — ready and in_progress on the default board — unclaimed, hard blockers done | ListDispatchable — 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 |
| admission | resolveExplicitBot, before the claim | admitBoardCard, before the claim; then gateLaunch as the synthetic board-dispatcher identity on the card's team |
| the run | runner.Dispatch in a per-issue workspace | runs.Launch onto the queue, then a 3 s poll to terminal |
last_run on the card | stamped by finishRun, when the run ends | stamped right after the launch, so the studio links the LIVE run |
| a pause | parked in awaiting_input, claim retained | moved to awaiting_input and released — sweepParked closes it later |
| a refused launch | the ordinary retry ladder | the 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-run | the force-reap arm reverts | the 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_stateonly 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:billylabel 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 siblingfor a running, queued or foreign-paused run;cancelled by the operator, which names--clear-last-runas the way out; an unreadable run or an unopenable store holds too, fail-closed. tracker.ErrClaimConflicton the claim — the journal entry is removed and no slot is allocated.- a dispatcher-owned paused
last_run, refused after the claim —reparkClaimedIfLastRunWaitingmoves 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, andin_progressbeing eligible is why a fresh sibling would otherwise be minted from the workflow entry. tracker.ErrClaimConflicton the in-progress move — the one error that inverts the best-effort rule:claim lost before launch, the run is never started.ErrTransitionRejectedandErrNotSupportedcontinue.tracker.ErrClaimConflicton a renewal —cmdClaimLostcancels only the run that owns the card now, withErrRunInterrupted; 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, neverblocked.gateLaunchdenials (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
| collection | written by | what |
|---|---|---|
board_issues | both twins | the 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_config | cloud twin | the 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_events | both twins | one event per content change — a lease renewal is liveness, so it appends nothing |
trigger_effects | server | the durable projection row a native card move materializes for the bound forge board |
forge_board_bindings | sync worker | the due list, the per-tenant sync lease and the degraded reason |
runs, events | engine, runner | read 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.
Related
- dispatcher.md — the local daemon's reference: the YAML, the actor model, the hooks, the tracker adapters, the HTTP and WS surface.
- native-tracker.md — the kanban the dispatcher polls; github-board-sync.md — the Projects v2 reconciliation.
- data-model-automation.md —
board_issues,board_config,board_eventsand the trigger spine; data-model-runs.md — the run and its status machine; data-model-forge-bots.md —forge_board_bindingsand the bot tiers. - flow-cloud-run-lifecycle.md — what
runs.Launchdoes with a card's run; access-control.md — the principal kinds and the launch gate. - adr/012-dispatcher-before-remove-hook-on-worker.md · adr/014-dispatcher-paused-run-parking.md · adr/015-dispatcher-retry-attempt-cap.md · adr/096-board-claim-lease-and-watchdog.md · adr/097-github-projects-v2-board-sync.md
