Continuous dispatch
How coordinate --watch fills free slots from the queue.
Why this exists
Before this, nothing pulled work on its own:
coordinate --watchrefreshed runs that were already in flight. It never dispatched.mainark dailydispatched at most one issue per calendar day, blocked supervising it for up to four hours, and — becauselistAutoReviewCandidates()skipstrigger === "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
| Env | Default | Purpose |
|---|---|---|
MAINARK_COORDINATE_AUTO_DISPATCH | off | Same as --auto-dispatch |
MAINARK_MAX_INFLIGHT | 2 | Max 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:
- Count inflight
implementruns.capacity = maxInflight - inflight; stop at zero. - 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 queuesmainark dispatchpicks from are not swept: they hold a backlog nobody approved for unattended spending. - Drop anything whose backoff has not elapsed (see failure policy).
- 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.