- Published on
loopwork: a task queue that feeds AI coding CLIs

- Authors

- Name
- Nadim Tuhin
- @nadimtuhin
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.
| Job | Where it lives |
|---|---|
| Hold the queue | packages/backend-json, packages/backend-github |
| Pick a model | packages/executor/src/model-selector.ts |
| React to events | packages/loopwork/src/core/runner.ts and plugins |
How it works

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.

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.
| Metric | Value |
|---|---|
| Commits on GitHub | 249, from 2026-01-24 to 2026-07-09 |
| Workspace packages | 61 directories under packages/ |
| Supported CLI types | 7 (claude, opencode, gemini, droid, crush, kimi, kilocode) |
| Test files | 646 |
it/test calls (grep) | about 6,200 |
| Published | @loopwork-ai/loopwork 0.3.6, loopwork 0.3.2 on npm |
| GitHub stars | 1 |
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:
- The half-open cap was never counted.
CircuitBreakercheckedhalfOpenCalls < halfOpenMaxCalls, but nothing incrementedhalfOpenCalls, so the limit of 3 probe calls was never enforced. The probe limit now works. - The lock helper was typed for sync code.
withLocktook() => Tand released the lock infinally, whileclaimTaskpassed anasynccallback. It was safe only because that callback had noawaitinside. The lock is now async. - The fallback backend broke its own rule. The header of
backends/fallback.tssays write operations only use the primary, butclaimTaskwent throughtryWithFallback.claimTaskis now primary-only. - The
capabilitystrategy was half wired.ModelSelectionStrategylisted it, butModelSelectorhad no case for it, sopeek()andgetNext()could disagree. The strategy now errors. - Auto-commit built shell strings.
plugin-git-autocommitrangit committhroughexecSyncwith an escaped message. It now usesexecFileSync.
Three are still open:
- GitHub claims are not atomic. Only the JSON backend implements
claimTask. With the GitHub backend, two workers can pick the same issue betweenfindNextTaskand the status update. - Packaging is unfinished. The
buildscript inpackages/loopwork/package.jsonis anechothat skips the build because of an Ink top-level await issue. The package ships TypeScript source, with separatebuild:distandbuild:binaryscripts. - 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.mdand several model-filtering scripts. Adocs/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
Related posts
- 9agent: run Claude Code, Pi or Hermes on any model with one command
A small launcher that points Claude Code, Pi or Hermes at a 9Router gateway, lets me pick any model, and can run the agent in Docker. It never rewrites configs.
- opencode-9router: model discovery for OpenCode from a 9Router gateway
An OpenCode plugin that asks a 9Router gateway for its models at startup, fills in limits and capabilities, and caches the list. v1.0.0, 26 tests after a six-fix PR.
- express-simple-proxy: an Express proxy with hooks and typed errors
express-simple-proxy forwards Express routes to an upstream API with error codes, a beforeRequest hook and an onResponse callback. 183 tests, two runtime dependencies, and rough edges I found.