Continuous dispatch

How coordinate --watch fills free slots from the queue.

Why this exists

Before this, nothing pulled work on its own:

  • coordinate --watch refreshed runs that were already in flight. It never dispatched.
  • mainark daily dispatched at most one issue per calendar day, blocked supervising it for up to four hours, and — because listAutoReviewCandidates() skips trigger === "daily" — never reviewed the result.

So a queue of ready issues sat untouched unless somebody opened an SSH session and typed mainark dispatch. That is the gap between "run one issue by hand" and "dispatch several and walk away".

Turning it on

Opt-in, off by default:

mainark coordinate --watch --auto-review --auto-dispatch
EnvDefaultPurpose
MAINARK_COORDINATE_AUTO_DISPATCHoffSame as --auto-dispatch
MAINARK_MAX_INFLIGHT2Max concurrent implement runs

MAINARK_MAX_INFLIGHT is deliberately low. Cloud implement runs remotely, but the review/fix loop that follows creates a local git worktree and can run the target repo's install and validate steps, so every slot has a real cost on the host. On a small box, raise it only after watching what one slot actually uses.

What a sweep does

Each tick, after the refresh so capacity reflects runs that just finished:

  1. Count inflight implement runs. capacity = maxInflight - inflight; stop at zero.
  2. Pick candidates from issues opted in to auto-dispatch (auto_dispatch=1, set only by a plan submitted with "autoDispatch": true, see plan inbox). They go oldest first, once every dependency is done. The epic queues mainark dispatch picks from are not swept: they hold a backlog nobody approved for unattended spending.
  3. Drop anything whose backoff has not elapsed (see failure policy).
  4. Dispatch up to capacity.

Every dispatch still goes through prepareImplementDispatch, so the dependency, terminal, paused and already-active guards apply exactly as they do manually.

Design notes

Backoff is enforced here, not just recorded. The failure policy releases a transient failure straight back to todo but sets next_retry_at in the future. Selecting on work_status alone would re-dispatch instantly and defeat the backoff entirely, so isBackoffPending() is checked before every dispatch.

A retry needs no special casing. The policy already returned the issue to todo, and prepareImplementDispatch rejects --retry for todo issues. So a retry is just a normal dispatch.

Rejections are reported, not thrown. One ineligible issue must not stop the sweep, and an unattended loop has nobody to show a stack trace to. Each skip records the DispatchRejectedError code as its reason.

A broken queue is a no-op, not a crash loop. If candidate selection throws, the error is returned as configError on the sweep result rather than thrown, so it cannot take down a watcher that is otherwise healthy.

Capacity counts implement runs only. Review and fix runs are children of an implement run that already holds its slot; counting them would halve throughput.

A dispatch the provider rejects is not a dispatch. dispatch() returns normally when Cursor refuses the create call and writes the run failed. The sweep checks for that, routes the failure through the policy straight away, and records it as dispatch_failed:<kind> rather than counting it against capacity.

Account-level failures stop the sweep. A rate_limit or auth failure applies to every issue, not just the one that hit it. The rest of that sweep is skipped (account_blocked), and while a rate_limit backoff is pending later sweeps are paused entirely (accountBlockedUntil) — otherwise the queue would be walked into the same limit one issue per tick.