Published on

loopwork: a task queue that feeds AI coding CLIs

loopwork: a task queue that feeds AI coding CLIs
Authors
On this page

Running one AI coding CLI on one task is easy. Running it on fifty tasks means picking the next task, choosing a model, surviving rate limits, and recording what happened. loopwork handles that bookkeeping.

loopwork is a TypeScript monorepo that takes a task backlog from GitHub Issues or a JSON file and runs a CLI such as Claude, OpenCode or Gemini against each task. This post walks through how the loop is built, what the numbers say, and what I would change.

The problem: a backlog is not a prompt

A single prompt has no memory of what is done. A backlog needs a status per task, a claim step so two workers do not take the same one, and a policy for failure. Rate limits add a twist: the useful response to a 429 is often to try another model.

The README puts the goal this way: "You write a task description once, and Loopwork orchestrates your AI model to execute all 50 tasks, tracking progress, retrying failures, and notifying you when done." The code splits that into three jobs.

JobWhere it lives
Hold the queuepackages/backend-json, packages/backend-github
Pick a modelpackages/executor/src/model-selector.ts
React to eventspackages/loopwork/src/core/runner.ts and plugins

How it works

Four steps of one loop: claim a task from the backend, pick a model, run the CLI, then hand the result to plugins. Rate limits loop back to model selection

1. Claim a task

Every backend implements the TaskBackend interface from packages/contracts/src/backend.ts. Its optional claimTask finds the next pending task and marks it in-progress in one step. Only the JSON backend implements it. The GitHub backend falls back to findNextTask, which claims nothing. The JSON backend claims under a file lock, so parallel workers on one file cannot race:

// packages/backend-json/src/json.ts
async claimTask(options?: FindTaskOptions): Promise<Task | null> {
  return await this.withLock(async () => {
    const data = this.loadTasksFile()
    // ...filter pending, failed past cooldown, quarantined past delay
    this.updateTaskEntryLifecycle(entry, 'in-progress', 'Status changed to in-progress (claimed)', { method: 'claimTask' })
    this.saveTasksFile(data)
    return this.loadFullTask(entry, data)
  })
}

A failed task becomes claimable again only after retryCooldown. A quarantined task comes back only if the dead-letter policy sets autoRetry, with a default delay of one hour.

2. Pick a model

ModelSelector holds a primary pool and a fallback pool. Strategies are round-robin, priority, cost-aware and random. Each model gets a circuit breaker. After 3 failures the breaker opens, and the model sleeps for 10 minutes before a half-open probe:

// packages/executor/src/circuit-breaker.ts
failureThreshold: options.failureThreshold ?? 3,
resetTimeoutMs: options.resetTimeoutMs ?? 600000, // 10 minutes (model sleep duration)

When every primary model is asleep, selectNextInternal calls switchToFallback() and tries the fallback pool. The defaults in cli-executor.ts are three primary models and two fallbacks.

One model's circuit breaker: closed until 3 failures, open for 10 minutes, then half-open with a probe call that either closes it or opens it again

3. Run the CLI

The executor talks to each CLI through a small strategy class. ClaudeStrategy sends the prompt on stdin and declares which stderr patterns mean rate limit (/rate.*limit/i, /429/) or quota exceeded. The path to each binary can be overridden with env vars such as LOOPWORK_CLAUDE_PATH.

Retries are configured in two places. The task-level DEFAULT_RETRY_POLICY in packages/loopwork/src/core/retry.ts uses 3 retries, a 1 second initial delay, a 30 second cap, multiplier 2 and jitter. The executor has its own settings in cli-executor.ts: 3 retries per model, a 60 second cap, multiplier 2, and a 2 second delay between model attempts as the backoff base. A separate RetryBudget caps total retries at 50 per rolling hour, which the source comment describes as preventing runaway costs.

4. Tell the plugins

LoopworkRunner in packages/loopwork/src/core/runner.ts exposes 14 hooks, from onConfigLoad to onCliResult. A plugin that throws is logged and counted. Three failures auto-disable it, unless it is marked critical:

// packages/loopwork/src/core/runner.ts
if (newCount >= this.MAX_FAILURES) {
  this.disabledPlugins.add(pluginName)
  logger.warn(`Plugin ${pluginName} has failed ${this.MAX_FAILURES} times and has been auto-disabled`)
}

Config is a pipeline of wrappers, in the style of Next.js plugins. compose is one line of reduce, so each withX() adds to the config and nothing else.

Results

All numbers are from the repository on 2026-10-14, before PR #8.

MetricValue
Commits on GitHub249, from 2026-01-24 to 2026-07-09
Workspace packages61 directories under packages/
Supported CLI types7 (claude, opencode, gemini, droid, crush, kimi, kilocode)
Test files646
it/test calls (grep)about 6,200
Published@loopwork-ai/loopwork 0.3.6, loopwork 0.3.2 on npm
GitHub stars1

bun test src/test/circuit-breaker.test.ts src/test/model-selector.test.ts in packages/executor reported 73 pass, 0 fail before PR #8. The other test files were not run for this post, so the grep count is a size estimate, not a pass rate.

There are two npm packages because I renamed the package. @loopwork-ai/loopwork is the current one, and loopwork is the legacy name.

The CLI list is longer than the one-line description of the project. The code supports seven CLIs, and the ModelSelector pools can mix them.

What I fixed after writing this

On 2026-10-14 I merged one PR that fixed five of the rough edges I found:

  1. The half-open cap was never counted. CircuitBreaker checked halfOpenCalls < halfOpenMaxCalls, but nothing incremented halfOpenCalls, so the limit of 3 probe calls was never enforced. The probe limit now works.
  2. The lock helper was typed for sync code. withLock took () => T and released the lock in finally, while claimTask passed an async callback. It was safe only because that callback had no await inside. The lock is now async.
  3. The fallback backend broke its own rule. The header of backends/fallback.ts says write operations only use the primary, but claimTask went through tryWithFallback. claimTask is now primary-only.
  4. The capability strategy was half wired. ModelSelectionStrategy listed it, but ModelSelector had no case for it, so peek() and getNext() could disagree. The strategy now errors.
  5. Auto-commit built shell strings. plugin-git-autocommit ran git commit through execSync with an escaped message. It now uses execFileSync.

Three are still open:

  1. GitHub claims are not atomic. Only the JSON backend implements claimTask. With the GitHub backend, two workers can pick the same issue between findNextTask and the status update.
  2. Packaging is unfinished. The build script in packages/loopwork/package.json is an echo that skips the build because of an Ink top-level await issue. The package ships TypeScript source, with separate build:dist and build:binary scripts.
  3. The repo root is noisy. 13 notes and scripts sit at the root beside the standard README and CONTRIBUTING files, including ROADMAP.md, MODEL_SLEEP.md and several model-filtering scripts. A docs/ move would help new readers.

None of these stop the loop from running. If you hit one of them, open an issue on the repo.

Resources