iterion

iterion dispatch — long-running dispatcher

The dispatcher turns iterion from a one-shot iterion run into a dispatcher: it polls an issue tracker, picks the next eligible issue, runs a workflow against it, and repeats — with retry, stall detection, per-state concurrency caps and hooks. It is the layer that makes “an AI sweeps the backlog” a real, supervisable thing rather than a cron + a prayer.

If you only want a kanban board with no autonomous loop, you don’t need the dispatcher — see docs/native-tracker.md for the standalone tracker.

Quick start (zero config)

The fastest path is no YAML at all:

iterion dispatch

Called without an argument, the dispatcher boots with a built-in preset: the native kanban tracker, the studio HTTP surface on http://localhost:4892, polling every 30 s, and an embedded bot catalogue that exposes the workflows from examples/ as assignees. Out of the box you can:

  1. Open http://localhost:4892/board and create a ticket.
  2. Set the ticket’s assignee to one of the names below, drop it into a state marked eligible (default: ready / in_progress), and the dispatcher picks it up at the next poll.
  3. The studio’s /dispatcher route shows the run in flight.

Once claimed, the ticket moves to in_progress and its run attaches to the card — a live status chip shows it executing right on the board:

Studio board — a dispatched ticket in progress with a live running run on the card

Open that run and the console records the ticket it came from (the From ticket header), closing the loop from issue to execution:

Studio run console — the From-ticket header linking a dispatched run back to its issue

Built-in assignees (source bots):

Persona Assignee Backing bot What it does
🛠️ Featurly feature-dev bots/feature-dev/ Autonomous feature dev: plan → act → simplify → alternating Claude/GPT review loop
🌍 Willy whole-improve-loop bots/whole-improve-loop/ Whole-codebase improvement loop with alternating cross-family verdicts
🌿 Billy branch-improve-loop bots/branch-improve-loop/ Branch-scoped improvement + auto-commit on convergence
🧭 Nexie whats-next bots/whats-next/ Repo survey → roadmap synthesis → kanban materialisation
📚 Doki docs-refresh bots/docs-refresh/ Detect & fix doc/code mismatches
🔎 Revi review-pr bots/review-pr/ Read-only cross-family code review; publishes findings to the board
🛡️ Seki sec-audit-source bots/sec-audit-source/ Source-code security audit (gitleaks/trivy/semgrep/gosec)
📦 Depsy sec-audit-deps bots/sec-audit-deps/ Supply-chain dep audit + LLM review
⬆️ Renovacy secured-renovacy bots/secured-renovacy/ Security-aware dependency upgrades with cumulative review
(unassigned) default/ (embedded) Generic triage agent: classifies the issue and recommends a next step

Each assignee’s input contract ( + → the bot’s main prompt var) is wired in pkg/cli/dispatch_defaults.go. Bots are extracted on first run under <store-dir>/dispatcher/bots/<name>/ (write-if-absent so local edits survive subsequent starts). Override the port via --port, the store location via --store-dir, or write a full YAML when you outgrow the defaults.

TL;DR — explicit YAML

# 1. Init the kanban + create a first issue.
iterion issue board init
iterion issue create --title "Investigate flaky test" --state ready --priority 5

# 2. Write an `iterion.dispatcher.yaml` next to your workflow.
cat > iterion.dispatcher.yaml <<'EOF'
name: dev-loop
workflow: ./workflow.bot
tracker:
  kind: native
dispatch:
  vars:
    user_prompt: "Issue : \n\n"
polling:
  interval_ms: 15000
agent:
  max_concurrent: 2
workspace:
  root: ./workspaces
server:
  port: 4892
EOF

# 3. Start the daemon. The dashboard lives at http://localhost:4892.
iterion dispatch iterion.dispatcher.yaml

The studio’s /dispatcher route renders the same daemon — its config, the in-flight runs, and the retry queue, with pause/stop controls:

Studio dispatcher dashboard with config card and run/retry tables

Mental model

flowchart LR
  TRK["Tracker<br/>(native / GH /<br/>Forgejo)"]
  DSP["Dispatcher<br/>(1 actor goro)"]
  RUN["Runner<br/>(engine = LLM<br/>+ tools)"]

  TRK -- "ListCandidates" --> DSP
  DSP -- "Claim /<br/>Update /<br/>Release" --> TRK
  DSP -- "Dispatch(spec)" --> RUN

A single goroutine — the actor — owns all mutable state. Outside callers (HTTP handlers, retry timers, the config watcher, dispatch goroutines reporting completion) send typed messages on a buffered channel. This mirrors Symphony’s GenServer design with fewer moving parts and zero shared locks across blocking tracker I/O.

State machine

Issues flow through:

stateDiagram-v2
  [*] --> Unclaimed
  Unclaimed --> ClaimedRunning : tick + slot available + tracker.Claim ok
  ClaimedRunning --> Unclaimed : Runner returned nil — tracker.Release,<br/>drop the run, free the slot
  ClaimedRunning --> RetryScheduled : Runner returned error / ctx cancelled<br/>(stall, user cancel, external state change)<br/>Schedule retry with exponential backoff,<br/>claim is freed
  RetryScheduled --> Unclaimed

The slot accounting is global (agent.max_concurrent) plus per-state (agent.max_concurrent_by_state). A workflow state in the per-state map cannot exceed its individual cap even when the global cap has room.

In-progress transition (agent.running_state)

After tracker.Claim succeeds, the dispatcher transitions the issue to agent.running_state (default in_progress) so the kanban shows which tickets are being worked on right now. Behaviour:

Event Action
Claim succeeds, source ≠ target UpdateState(id, running_state), record source
Claim succeeds, source == target No-op (idempotent)
Claim succeeds, transition rejected Log warn, continue (the claim is already taken)
running_state: none (or YAML empty) Transition disabled — issues stay in their source
Workspace create / runID mint fails Revert state, release claim
Run cancelled (context.Canceled) Revert state, release claim, keep workspace
Run failed (non-cancel) Revert state, release claim, schedule retry
Run finished cleanly (err == nil) No revert. The workflow has either moved the
  state itself (e.g. docs-refresh → review) or the
  operator wants to inspect it in running_state.
Daemon shutdown (Ctrl+C, SIGTERM) Revert each in-flight ticket’s transition

Every revert is best-effort and protected by a RefreshStates safety check: the dispatcher only flips the state back when the issue is still in running_state. If the workflow already moved it forward (e.g. docs-refresh → review) or the operator dragged the card on the kanban mid-run, the revert is skipped so the operator’s action isn’t clobbered.

To disable the transition (e.g. boards without an in_progress column), set agent.running_state: none:

agent:
  max_concurrent: 2
  running_state: none   # keep claimed issues in their source state

External trackers (GitHub, Forgejo) map the abstract state to labels; if the YAML’s state_mapping doesn’t declare in_progress, UpdateState returns ErrTransitionRejected and the dispatcher logs + continues without aborting the dispatch.

Polling tick

Each tick (polling.interval_ms, default 30s):

  1. Reconcile stalled. For every in-flight run, if time.Since(LastEventAt) > stall.timeout_ms, cancel its context. The worker goroutine then returns and the actor schedules a retry. Set stall.timeout_ms: 0 to disable.
  2. Refresh tracker states. Ask the tracker for the current state of every running issue. If the state moved out of the eligible set (operator closed the GitHub issue, dragged the native card to “done”), cancel the worker. The dispatch is yielded back to the tracker as the source of truth.
  3. Fetch candidates. tracker.ListCandidates(ctx). The native adapter filters by Eligible board states; the GitHub adapter passes labels through gh issue list --search.
  4. Sort. priority desc, created_at asc, identifier asc.
  5. Dispatch. Walk candidates, skip those already claimed locally or queued for retry, and dispatch as long as both global and per-state slots have room.
  6. Broadcast snapshot. Publish to the WS bridge so the dashboard shows the new state.

Retry queue

Trigger Delay
Runner returned nil Released, no retry.
Runner returned error min(10s × 2^(attempt-1), agent.max_retry_backoff_ms)
Stall timeout Same exponential backoff
External state change Same
Hook failure (before_run) Same

Retries are timer-driven (time.AfterFunc per issue, no min-heap). The timer callback posts cmdRetryDue{issueID} on the actor channel and the next tick reconsiders the candidate (which may by then have moved out of the eligible set — fine, the dispatcher releases without re-dispatching).

Workspace lifecycle

<workspace.root>/<sanitized-issue-id>/ is created on first dispatch for that issue, preserved across retries (so the agent’s incremental state survives a failure), and removed when the issue reaches a terminal state — pending workspace.persist policy.

workspace.persist Behaviour
keep Never delete.
cleanup_on_done Delete when the engine returns success.
cleanup_on_terminal Delete when the tracker state hits a terminal state. (default)

The sanitize regex is [^a-zA-Z0-9._-]_, with a leading dot escaped (so an issue named .gitignore doesn’t produce a hidden dir). The resolver refuses workspaces whose symlink resolution lands outside the configured root.

These dispatcher workspaces are distinct from the engine’s per-run worktree: auto — the latter is the runtime’s git-isolation mechanism and lives inside the dispatcher workspace. Both layers keep their independent lifetimes.

Hooks

hooks:
  after_create:                       # runs once, when the workspace dir
    script: |                         # is first created.
      git clone --depth 1 https://github.com/${ORG}/${REPO} .
    timeout_ms: 120000
  before_run:                         # runs before every dispatch.
    path: ./scripts/prepare.sh        # `path:` invokes a script; `script:`
    timeout_ms: 60000                 # inlines a shell snippet. Exactly
                                      # one of the two must be set.
  after_run: null                     # runs after every dispatch (success
                                      # or failure). Best-effort: failures
                                      # are logged, not surfaced.
  before_remove: null                 # runs just before the workspace dir
                                      # is removed (commit + push your work
                                      # here if you want to keep it).

Hooks execute via sh -lc with cwd=<workspace path>. The dispatcher exports five environment variables before invoking the hook:

Var Value
ITERION_ISSUE_ID full ID, e.g. native:<uuid>
ITERION_ISSUE_IDENTIFIER human-readable, e.g. repo#42
ITERION_ISSUE_STATE current workflow state
ITERION_RUN_ID the engine run ID for this dispatch
ITERION_WORKSPACE absolute workspace path

A failed after_create or before_run aborts the dispatch and feeds the retry queue; failed after_run / before_remove are logged at WARN.

Dispatch templates

The dispatch.vars block maps workflow input vars to per-issue values using the same `` syntax the .bot DSL exposes — but with a narrower set of namespaces.

Attachments are not dispatchable. There is no dispatch.attachments support: workflow attachments are binary files (referenced as ``), and the dispatcher has no way to turn a per-issue template string into an attachment’s bytes. Declaring dispatch.attachments (or assignee_dispatch[].attachments) is a load-time error, not a silent no-op — pass per-issue context through dispatch.vars or a ticket’s bot_args instead. See ADR-013.

Reference Resolves to
`` full tracker ID
`` human label
`` issue title
`` issue body
`` (alias of workflow_state) current state
`` priority as integer
`` assignee login
`` comma-joined label list
`` bracketed [a,b] form
`` metadata URL (native: empty, GH/Forgejo: html_url)
`` / updated_at RFC3339 timestamp
`` typed value of a custom field (native only)
`` adapter-specific metadata
`` the name: from your config
`` the dispatch’s run ID
`` absolute workspace path
`` 0 on first try, N for the (N+1)th retry

The set of references is closed at parse time: typos like `` fail config validation rather than silently rendering an empty string at dispatch.

Routing by issue assignee

By default the dispatcher dispatches a single workflow (workflow:) for every eligible issue. To dispatch different workflows for different assignees — without running multiple dispatcher instances — add an assignee_workflows: map:

name: dev-loop
tracker:
  kind: native

workflow: workflows/triage.bot                  # default fallback

assignee_workflows:
  feature_dev:        bots/feature-dev/main.bot
  whole_improve_loop: bots/whole-improve-loop/main.bot
  secured-renovacy:   bots/secured-renovacy/main.bot

Resolution rules at dispatch time:

  1. If issue.Assignee is non-empty AND present in assignee_workflows, the dispatcher uses the mapped workflow.
  2. Otherwise (empty assignee, or assignee not in the map), it falls back to workflow:.

Matching is exact and case-sensitive. There is no glob / regex / pattern syntax — keep the keys aligned with what the producer stamps into --assignee. For the native tracker, the iterion issue create --assignee <name> flag drops name straight into issue.assignee; GitHub and Forgejo adapters use the first assignee’s login.

Each assignee_workflows workflow is pre-compiled at startup and reused across dispatches — the same lifecycle as the default workflow:. Path resolution is relative to the dispatcher config file (same convention as workflow:). Missing files fail iterion dispatch startup with a precise error.

This is what makes whats-next.bot’s kanban output auto-pilot: the bot stamps each issue with --assignee feature_dev (or any catalogued bot), and the dispatcher — with the mapping above — dispatches the matching workflow without any operator intervention.

Per-ticket bot + args fields

In addition to the assignee-based mapping above, every native tracker issue carries two dedicated typed fields that are copied into the dispatch request:

Field Type Current stock effect
Bot string (JSON bot) When non-empty, becomes the dispatch routing key: buildSpec sets routeAssignee = iss.Bot (winning over the issue’s own assignee) and carries it on the spec as DispatchSpec.Assigneenot a workflow path. RoutingRunner selects the precompiled per-bot EngineRunner (its ByAssignee map is keyed by bot/assignee name) and the matching assignee_dispatch var overrides from that key; the bot FILE itself is resolved + route-checked by the guard at the top of dispatch() (the issue is skipped with a warning if the bot can’t be resolved or has no active route). Use assignee_workflows: for production workflow routing today.
BotArgs map[string]string (JSON bot_args) Merged over the rendered dispatch.vars key-by-key at launch time. BotArgs wins on shared keys; keys absent from the workflow’s vars: schema are passed through with a warn log (the engine surfaces its own diagnostic).

Current stock workflow selection is performed by the runner built at iterion dispatch startup:

  1. assignee_workflows[issue.assignee] → a precompiled per-assignee EngineRunner selected by RoutingRunner.
  2. cfg.workflow → the precompiled default EngineRunner.

buildSpec folds a per-ticket Bot into the routing key DispatchSpec.Assignee (it wins over the issue’s own assignee); the RoutingRunner above then selects the matching precompiled EngineRunner by that key, exactly as it does for an assignee_workflows assignee. DispatchSpec carries no workflow path — each EngineRunner runs the IR it was constructed with, so to route a brand-new workflow per ticket you add it to assignee_workflows: (or supply a custom runner that keys off DispatchSpec.Assignee).

Vars: assignee_dispatch[issue.assignee].vars (or dispatch.vars as fallback) are rendered first, then BotArgs is merged on top. See pkg/dispatcher/loop.go (buildSpec, lines 276-296) for the merge, and pkg/dispatcher/routing_runner.go for the stock assignee workflow selection.

How to set bot / bot_args today: REST API only — POST /api/v1/native/issues or PATCH /api/v1/native/issues/{id} with { "bot": "feature_dev", "bot_args": { "feature_prompt": "…" } }. The iterion issue create/update CLI does not yet expose dedicated bot-selection or bot-argument flags; --field key=value lands in the freeform Fields map, not in BotArgs, and is not merged into dispatch vars. Operators driving routing purely through the CLI should rely on assignee_workflows: + assignee_dispatch: instead.

Per-assignee dispatch overrides

Different bots expect different input vars: feature_dev wants feature_prompt, whole_improve_loop wants improvement_prompt, secured-renovacy wants user_prompt. The global dispatch.vars: binds a single template for every assignee, which doesn’t fit a heterogeneous bot catalogue.

assignee_dispatch: solves that — when an issue’s assignee has an entry here, its vars: replace the global dispatch.vars wholesale for that dispatch:

workflow: workflows/triage.bot
assignee_workflows:
  feature-dev:        bots/feature-dev/main.bot
  whole-improve-loop: bots/whole-improve-loop/main.bot
  secured-renovacy:   bots/secured-renovacy/main.bot

assignee_dispatch:
  feature-dev:
    vars:
      workspace_dir:  ""
      feature_prompt: "\n\n"
  whole-improve-loop:
    vars:
      workspace_dir:      ""
      improvement_prompt: "\n\n"
  secured-renovacy:
    vars:
      workspace_dir: ""
      user_prompt:   "\n\n"

dispatch:
  # Fallback for issues with no assignee or an unmapped one.
  vars:
    issue_title: ""
    issue_body:  ""

Validation rules:

The zero-config mode (iterion dispatch) uses exactly this mechanism to wire each embedded bot to the issue title/body — see pkg/cli/dispatch_defaults.go.

Deterministic ticket router (PR-aware)

Opt-in. When enabled, an unassigned new issue (no Bot, no Assignee) is routed BEFORE the normal resolution by whether a PR already links it:

ticket_router:
  enabled: true
  implement_bot: feature-dev   # bot for a PR-less issue (default)

GitHub setup note. GitHub’s gh issue edit --add-label errors if the label doesn’t already exist in the repo, so the visible bot:featurly / bot:billy labels (and the tracker’s claimed_label) must be pre-created (gh label create bot:featurly …). The label apply is best-effort and never blocks the routing decision, but the claim (same --add-label seam) does — an issue can’t be dispatched until its claimed_label exists. A GitHub issue also needs a state_mapping state to be a candidate at all (an unlabeled issue with no mapped state is skipped). The native tracker auto-manages its labels, so this only applies to the github/forgejo adapters.

The PR-existence check + the visible bot:* label are best-effort tracker capabilities (HasLinkedPR / ApplyLabel, type-asserted at runtime). The GitHub adapter implements both via the gh CLI; a tracker that can’t answer “does this issue have a linked PR?” (native/forgejo today) degrades to routing every unassigned issue to the implement bot — it never blocks an issue and never dedups against a PR-webhook it can’t observe. An explicit per-ticket Bot/assignee always wins; the router only touches fully-unassigned issues. Implemented in pkg/dispatcher/loop.go (routeUnassignedIssue).

Hot-reload

The dispatcher watches iterion.dispatcher.yaml via fsnotify with a 200ms debounce. On a valid edit, the new config is swapped in:

Field Effect on edit
polling.interval_ms new tick cadence next loop
agent.max_concurrent[_by_state] applied next dispatch decision
agent.running_state applied next dispatch + revert
agent.max_retry_backoff_ms applied next retry calc
hooks.* applied next dispatch
dispatch.vars applied next dispatch
stall.timeout_ms applied next tick
workflow:, tracker.kind:, workspace.root warn-only; require restart
tracker.* credentials warn-only; require restart

Invalid reloads (YAML errors, template parse errors, missing workflow file) keep the previous config and log a warning.

Tracker adapters

tracker.kind: native

The kanban store iterion ships with. Storage lives at <store-dir>/dispatcher/:

board.json                     # state + custom-field schema
issues/<id>.json               # one file per issue
events.jsonl                   # append-only audit log

See docs/native-tracker.md for the full reference.

tracker.kind: github

Shells out to the gh CLI. Auth uses the existing gh auth login by default; set tracker.github.token: $GITHUB_TOKEN for headless / CI.

tracker:
  kind: github
  github:
    repo: SocialGouv/iterion
    token: $GITHUB_TOKEN                # optional
    include_labels: [dispatcher-eligible]
    exclude_labels: [blocked, on-hold]
    claimed_label: iterion-claimed      # default
    state_mapping:
      ready:       { labels_include: [ready],   labels_exclude: [claimed] }
      in_progress: { labels_include: [claimed] }

The dispatcher’s Claim adds iterion-claimed; Release removes it. ListCandidates filters via gh issue list --search so pagination and rate-limit handling come for free.

Environment hygiene. When tracker.github.token is set, iterion exports it as GH_TOKEN / GITHUB_TOKEN only to the gh subprocess, and restricts the inherited environment to a curated allowlist (PATH, HOME, locale, proxy, ssh-agent, gh and git config vars). This prevents unrelated secrets in iterion’s environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, FORGEJO_TOKEN, …) from leaking to gh’s children via /proc/<pid>/environ. GH_TOKEN itself remains visible to gh’s direct subprocesses (e.g. the git it shells out to for clone/push) — that is intrinsic to the env-based auth and only avoidable by writing the token into gh’s on-disk credentials file via gh auth login --with-token. If your threat model includes co-located untrusted same-uid processes, prefer pre-authenticating gh interactively and leaving tracker.github.token empty.

tracker.kind: forgejo

Direct REST client against the Forgejo (Gitea-compatible) API. Auth is Authorization: token $FORGEJO_TOKEN.

tracker:
  kind: forgejo
  forgejo:
    host: https://codeberg.org
    repo: owner/repo
    token: $FORGEJO_TOKEN
    include_labels: [ready]
    state_mapping:
      ready:       { labels_include: [ready] }
      in_progress: { labels_include: [claimed] }

Same label-driven semantics as GitHub; label updates go through PUT /api/v1/repos/<owner>/<repo>/issues/<n>/labels so iterion does not need to resolve numeric label IDs.

HTTP / WS surface

The server.port setting starts the dispatcher’s HTTP server (the same SPA the studio serves, so you get the kanban + dashboard at http://localhost:<port>).

Endpoint Method Description
/api/v1/dispatcher/state GET Live snapshot (running, retries, slots).
/api/v1/dispatcher/refresh POST Force an immediate tick.
/api/v1/dispatcher/reload POST Re-parse the YAML config.
/api/v1/dispatcher/issues/{id} GET Per-issue dispatcher view.
/api/v1/dispatcher/issues/{id}/cancel POST Cancel an in-flight run.
/api/v1/dispatcher/ws WS Snapshot stream (push on each tick).
/api/v1/native/* Kanban store CRUD (when native is wired).
/api/server/info GET SPA bootstrap (flags dispatcher_enabled, native_tracker_enabled).

Single-instance safety

The dispatcher refuses to start a second instance against the same workspace root: it holds an exclusive flock on <workspace.root>/.dispatcher.lock for its lifetime.

For multiple dispatchers against the same tracker but different filesystems (e.g. dev laptop + CI), the per-issue claim marker (iterion-claimed label on GH/Forgejo, claim: field on native) prevents simultaneous dispatch — each dispatcher writes its own marker and refuses to dispatch issues marked by anyone else.

Operational tips

Deferred to v2