Skip to content

About

Weighted job admission for parallel AI coding agents: one machine-wide queue for lint, tests, builds and e2e, so agents stop overloading the machine.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

105 Commits

Folders and files

Repository files navigation

loadmaster

Weighted job admission for parallel AI agents.

Run five AI coding agents in parallel (say, Claude Code in five git worktrees) and each one runs lint, tests, builds and e2e on its own. They fight over CPU and memory, tests time out for no real reason, and agents start "fixing" bugs that are really just an overloaded machine.

loadmaster puts those commands behind one machine-wide queue. Unlike queues and semaphores that cap the number of concurrent jobs, it knows that an e2e run is much heavier than a lint: every task has a weight, the machine has a capacity, and a job starts only when its weight fits.

With capacity 8, lint = 1, test = 4 and e2e = 8:

  • one test and four lints run together (4 + 4 = 8)
  • two tests run together
  • e2e runs alone

A count-based limit can't express this: set it low and the machine idles while lints wait; set it high and three e2e runs at once bring the machine down.

The line is first come, first served: a small job passes a big one only where it cannot be in its way when the big one starts, so a big job is never starved by a stream of small ones. CPU is not the only limit: tasks can also use named resources, such as a few local database stacks, memory, or the one dev server port a checkout has.

At a glance

Problem Several AI agents on one machine run heavy checks at the same time. CPU and memory contention causes timeouts that look like test failures, and a sluggish machine.
Approach Every task has a weight (≈ CPU cores it keeps busy), the machine has a capacity, and the line is first come, first served. Tasks can also use named resources (database stacks, memory), limited per machine or per checkout; a job waiting for one doesn't hold up jobs that don't need it.
Interface A command wrapper: loadmaster run <task> [-- args]. No daemon or server; processes coordinate through a SQLite file.
Crash safety Places and slots of killed processes are reclaimed automatically (PID plus start time, so reused PIDs don't fool it). A task keeps its slot while it runs, even if loadmaster itself is killed.
Agent support Made for AI agents as its users: every line says what happened and, where there is something to do, the exact command (loadmaster: next:). loadmaster guide tells an agent how to run checks here, from the binary itself; loadmaster doctor checks that its tool's sandbox and timeouts let loadmaster work, and says what to change; loadmaster tune replays the machine's history with other weights before an agent changes them. Exit code 75 with a kept place in line when a wait exceeds --max-wait, so a tool timeout never sends an agent to the back of the line. loadmaster held lets scripts refuse to run outside the queue, whatever the agent. loadmaster cancel stops an agent's own checks without touching other agents' runs of the same commands. loadmaster status says what each of this checkout's jobs waits for and behind which jobs, and what became of its latest checks. Works with any agent that runs shell commands, inside sandboxes too; ready-made settings for Claude Code and Codex: instructions, sandbox, timeouts, a hook that blocks direct runs.
Measuring loadmaster report shows failures, timeouts, run and wait times and the cores each task really used; a measure-only mode records a baseline to compare with. See Checking that it helps.
Platforms macOS and Linux. On Windows, WSL2 should work (it runs the Linux binary) but is not tested yet; native Windows is not supported.
Integration cost One binary, one .loadmaster.toml per repository, a few lines of agent instructions. Removing it means deleting those, ~/.local/state/loadmaster and ~/.config/loadmaster.
Status 0.17, new. Verified with tests, soak tests, and real Claude Code and Codex sessions; see Status.
License MIT or Apache-2.0

Is it for you?

Use loadmaster when:

  • several agents (or agents and you) run lint, tests, builds or e2e on the same machine at the same time;
  • those checks differ a lot in cost, so "at most N at a time" either wastes the machine or overloads it;
  • the agents can be told, or made, to run checks through a command (agent instructions, permission rules or hooks).

Look elsewhere when:

  • only one agent runs at a time, or the checks are light: there is nothing to coordinate;
  • the checks run on several machines or in CI: loadmaster coordinates a single machine;
  • you need priorities, preemption, or limits based on measured CPU or memory: loadmaster uses static weights;
  • you need native Windows (WSL2 should work, but is not tested yet; see Install).

Not sure? Record a baseline first and compare; see Checking that it helps.

Compared with alternatives

Option What it does Why it falls short here
Count-based queues and semaphores (Block's agent-task-queue, flock, GNU sem) Run at most N jobs at once. N can't tell a lint from an e2e suite: low N idles the machine, high N overloads it.
nice, CPU quotas, containers Change how CPU time is shared. Everything still runs at once; tests get slower and time out instead of waiting their turn.
Lower test parallelism in each agent Smaller footprint per run. Five agents still add up, and nothing coordinates them.
Run checks only in CI Moves the load off the machine. Agents iterate locally; a round trip through CI is slow.

Status

loadmaster is new (0.17). What has been verified:

  • Integration tests that start real loadmaster processes check that the capacity is never exceeded (from the tasks' own records), that no job is delayed by the jobs behind it, that kill -9 of waiting and running jobs is recovered from, nested runs, exit codes and config errors. They run on macOS and Linux, also in a container without an init process.

  • A soak test simulates five agents with Claude Code-style timeouts (SIGTERM, then SIGKILL of the process group), re-runs after exit code 75, and crashes, in two checkouts with two resources besides CPU, while jobs are canceled at random; one task stops on SIGINT, as Playwright does. 824 runs (237 of them canceled) in 5 minutes on macOS and 513 in 3 minutes on Linux exceeded no limit, every task got through, and nothing was left behind. With jobs adapting to CPU busy outside the queue, beside 10 busy processes that came and went, 493 runs in 3 minutes on macOS (60 of them started with fewer cores) did the same.

  • A simulation checks, on random workloads with several resources and with CPU busy outside the queue that changes on its own, that no job is ever delayed by jobs that got in line after it and that every job starts; rules broken on purpose are caught. Against a search of every set of jobs that could run at once, it checks that where the jobs record the same limits, no job waits that could start without the risk of taking the machine past one.

  • Five real Claude Code sessions (Sonnet, headless) in five git worktrees of a demo repository, set up as described in Using it with AI coding tools, ran lint, test and e2e three times each on a 14-core Mac (capacity 8, weights 1/4/8, LOADMASTER_MAX_WAIT=60). The checks were stand-in scripts that sleep for 2, 10 and 20 seconds. All 45 checks went through loadmaster, the 12 waits that hit --max-wait were resumed by re-running, the hook blocked the one direct run, and the capacity was never exceeded. Checks waited 26 seconds on average and 60 at most: with fifteen 20-second e2e runs that each need the whole machine, that time has to go somewhere, and waiting replaces the timeouts it would otherwise cause.

  • With real tools (biome, vitest, Playwright with a webServer) on the same Mac, five agents verifying at once (test, then e2e, each in its own worktree):

    Setup Wall time vitest Playwright
    Run directly, default parallelism 39–45 s all 5 runs failed: 120 tests timed out passed
    Run directly, 4 workers each 36 s passed passed
    loadmaster, measured weights (capacity 12, test 4, e2e 2) 39–40 s passed passed
    loadmaster, e2e weighted 8 (capacity 8) 126 s passed passed

    The same tests pass alone in 3 seconds. With weights that match what the tasks really use, loadmaster removed the false failures at no cost in time; an overestimated weight made everyone wait.

  • Five real Claude Code sessions then ran lint, test and e2e three times each on that project with the measured weights: all 45 checks passed through loadmaster (longest wait 8.6 s), and the hook blocked a direct npx vitest run.

  • In real use, in a project with 39 worktrees on a 14-core Mac: with weights that had been set to the whole machine, the checks waited up to 70 minutes, and a timing-sensitive test, weighted as the whole machine to run it alone, held back every job behind it while it waited. With weights taken from loadmaster report and that test's exclusion moved to a resource of capacity 1, the 90th-percentile wait of most tasks fell from 13 to 16 minutes to 2.5 to 8 over the next 158 checks (90 minutes), and none failed under load. The load average when jobs ended rose (median 7.5 to 15), as more ran side by side; its peaks came with a task that used more cores than its weight.

  • A real Codex agent (Codex CLI 0.162, codex exec, its workspace-write sandbox with the state directory added as described in Using it with AI coding tools), told only by AGENTS.md, ran lint and test through loadmaster while another job held the whole machine: it waited, re-ran right after exit code 75 and resumed its place, and a wait of 2 minutes 24 seconds ran to the end. In the same sandbox, status, waiting behind other agents' jobs, reclaiming dead ones and cancel from a later command all worked, and the PreToolUse hook below blocked npx vitest run while letting loadmaster run lint through.

  • Fresh Claude Code and Codex sessions, given only the instruction line, on thirteen scenarios built from that project's transcripts (logs of checks run in parallel, a check failing under load outside the queue, a guard test, other checkouts' jobs in line), three runs each, against 0.16.1: what they read of wait lines in logs fell to 28%, a check that failed under outside load got through 6 of 6 times instead of 1, a guard test's refusal was no longer reported as a warning, and status answered in one call with 10 to 41% of its output. See eval/agents.

Not verified yet: weeks of real use. loadmaster only starts commands, so other tools should work too, but see Limitations for tools that use the terminal, leave daemons behind or start their own process groups.

Install

Prebuilt binaries for macOS (Apple Silicon and Intel) and Linux (x86_64 and arm64, statically linked) are on the releases page. With mise:

mise use -g github:ktanaka101/loadmaster

Append @0.17.0 to pin a version. Without mise, download the archive for your platform, such as loadmaster-aarch64-apple-darwin.tar.gz, and put the loadmaster binary it contains on your PATH.

On Windows, use the Linux binary inside WSL2, and run the agents and their checks in the same WSL2 distribution: loadmaster only coordinates runs that share its state directory, so checks started on the Windows side, or in another distribution, are not seen. This has not been tested yet.

To build from source instead (Rust 1.88 or later):

cargo install --locked --git https://github.com/ktanaka101/loadmaster --tag v0.17.0

Quick start

Add .loadmaster.toml to the repository root and commit it, so every worktree shares it:

# weight ≈ how many CPU cores the task keeps busy; measure yours
# (see "Choosing weights" below)
[tasks.lint]
weight = 1
cmd = ["npx", "biome", "check", "."]

[tasks.test]
weight = 4
cmd = ["npx", "vitest", "run", "--maxWorkers=4"]

[tasks.e2e]
weight = 2
stop_signal = "SIGINT"  # Playwright shuts its webServer down only on SIGINT
cmd = ["npx", "playwright", "test", "--workers=4"]

Then:

loadmaster run test
loadmaster run test -- src/parser.test.ts
loadmaster status

Every run says on stderr which job it is and how it ended (loadmaster: #42 `test` started, loadmaster: #42 `test` passed in 1m10s (exit 0)). While a job waits, it says what it waits for and behind whom when it starts waiting (on a terminal, also when that changes and at least once a minute), such as loadmaster: #42 `test` is waiting (weight 4, 2 jobs ahead) for 4 cores (6 of 8 in use); running: #41 `e2e` for 1m02s (usually 3m10s). The task's stdout and stderr pass straight through and loadmaster exits with the task's exit code. loadmaster guide tells an agent all of this, and this repository's tasks.

loadmaster check

checks .loadmaster.toml and the machine config without touching the queue, and warns about weights that will make jobs wait on this machine; it also works in CI.

Rules for agents running checks

If you are an AI agent working in a repository that already uses loadmaster (it has a .loadmaster.toml), run loadmaster guide and follow it: it says the rules for the version installed, and this repository's tasks. In short:

  • Run checks only through loadmaster: loadmaster run <task>, extra arguments after -- (loadmaster run test -- src/a.test.ts). Never run the underlying commands directly (npx vitest, npm test, a script from cmd): other agents share this machine, and running outside the line overloads it and makes everyone's checks time out for no real reason.

  • One loadmaster command per tool call, with nothing chained to it (; echo $?, && ...): its exit code is the check's, and a chained command may need approval.

  • Waiting is normal. loadmaster: #42 test is waiting ... says what it waits for and behind whom. Don't stop it or start it again; a tool timeout must cover the wait, the run and its clean-up.

  • Read how it ended from its last loadmaster: line, not only the exit code, since a task may exit with 75 or 125 itself (a run inside another check says nothing of its own, and if the line was lost, loadmaster status lists this checkout's latest results):

    Last line Exit code What to do
    #42 test passed ... 0 Continue.
    #42 test failed ... (exit N) N The check's result.
    #42 test gave up after waiting ..., then loadmaster: next: run ... again from ... within 120s 75 Not a failure: run the command it gives, word for word, from there, right away; it resumes the place.
    loadmaster: error: ..., maybe loadmaster: next: ... 125 loadmaster itself failed; do what next: says, don't retry blindly.
    #42 test could not start 126, 127 A setup problem, e.g. dependencies not installed.
    #42 test was killed by SIGTERM ... 128 + n Interrupted, not failed; run it again if needed.
  • loadmaster status shows this checkout's jobs, what each waits for and behind which jobs, and what became of its latest checks; --all lists every job.

  • To stop your own checks, use loadmaster cancel: loadmaster cancel 43 one job (its ID is on its first line and in loadmaster status), loadmaster cancel --here every job started from this checkout, other agents' working in it too. Never stop checks by matching command lines (pkill -f vitest): other agents run the same commands.

Configuration

Configuration has two layers: weights belong to the repository, capacity belongs to the machine. Agents in different repositories then share one budget, and no single call can bring its own idea of the capacity.

Repository: .loadmaster.toml

loadmaster looks for .loadmaster.toml in the current directory and then in each parent directory, up to the root of the repository it is in (the nearest directory with a .git, which linked worktrees and submodules have too), and no further. Tasks run in the directory that contains it.

Commit it on every branch your worktrees use. A worktree inside the main checkout (for example under .claude/worktrees/) whose branch has no .loadmaster.toml does not pick up the main checkout's file, whose tasks would run against the main checkout's code: loadmaster says so and fails.

Key Meaning
description Optional, one line: what the task checks and how to run part of it ("Unit tests; pass test files after --"). loadmaster guide shows it to the agent choosing a task, and the error for an unknown task lists it.
weight Integer ≥ 1. Use "roughly how many CPU cores this task keeps busy" as the unit, in every repository, because all repositories on the machine share one capacity. loadmaster tune measures it and shows what another would do.
min_weight Optional: the fewest cores the task may start with, from 1 to its weight. Set it only for a task that sizes its parallelism by LOADMASTER_WEIGHT (vitest --maxWorkers=$LOADMASTER_WEIGHT, eslint --concurrency=$LOADMASTER_WEIGHT): it then starts with what is left whenever that fits, rather than waiting for its whole weight, and runs longer with fewer workers. Without it, a task always starts with its weight. A task that holds a scarce resource while it runs (a browser, a database) holds it longer with fewer cores, which may keep others waiting longer than they gain.
cmd Array: the program and its arguments. No shell is involved. Arguments after -- are appended to the end exactly as given.
uses Optional: the resources the task uses, such as { e2e-db = 1 }; see Resources besides CPU.
ok_exit_codes Optional: the exit codes that mean the check passed, for the history log and loadmaster report; [0] if not set. A check that reports "differences found" with exit code 2 would set [0, 2]. loadmaster itself still exits with the task's code.
stop_signal Optional: the signal that asks the task to stop and clean up, one of "SIGTERM" (the default), "SIGINT", "SIGHUP", "SIGQUIT", "SIGUSR1", "SIGUSR2". A cancel, or a SIGTERM to loadmaster (as from an agent's timeout), reaches the task as this signal; see Signals. Playwright cleans up its webServer only on SIGINT, so a Playwright task should set "SIGINT". The task gets the signal's default action even if loadmaster was started with it ignored (as a script's background job is with SIGINT), but commands the task's own scripts start in the background (cmd &) still ignore SIGINT.

Unknown keys are errors, so a typo fails instead of being ignored. That also means an older loadmaster rejects a key added in a newer version, and branches and machines often run different versions. Name the oldest version your file needs at its top, and older versions say so instead of rejecting the key:

min_version = "0.5.0"

(loadmaster 0.4 and older do not know min_version either, and reject it as an unknown key.) In CI, loadmaster check validates the file.

The weight does not change with the arguments: loadmaster run test -- one.test.ts still counts as 4. Extra arguments come after the fixed ones, so leave out of cmd whatever callers should be able to pass (a path, for example), and note that relative paths are resolved from the directory containing .loadmaster.toml. When the cost depends a lot on the arguments, as for a whole test suite and a single test file, define a task for each:

[tasks.test]
weight = 8
cmd = ["npx", "vitest", "run"]

[tasks.test-file]
weight = 1
cmd = ["npx", "vitest", "run", "--maxWorkers=1"]

Choosing weights: measure, don't guess. Run the task alone and divide its CPU time by its wall time:

/usr/bin/time -p npx playwright test --workers=4

(user + sys) / real is the number of cores it keeps busy. Guesses are easily far off: in a demo project, vitest with 4 workers kept 3.8 cores busy, but Playwright with 4 workers only 1.2, because browser tests mostly wait. A weight that is too high costs real time: with e2e weighted 8 instead of 2, five agents verifying at once took 126 seconds instead of 40, all of it spent waiting. If memory or something else rather than CPU runs out, declare it as a resource instead of inflating the weight. Once tasks run through loadmaster, loadmaster report shows the cores each one really kept busy (CORES), measured the same way. It counts only the task's own processes: load the task causes elsewhere, such as in Docker containers or a database server it talks to, is not in it, so size such tasks by hand (docker stats and the load average help).

Resources besides CPU

When something other than CPU runs out, declare it as a resource and list what each task uses:

[resources.e2e-db]
capacity = 2      # at most 2 at once on this machine
per_checkout = 1  # and 1 per checkout: they share its ports and database

[resources.memory]  # GiB; the machine config sets the capacity

[tasks.e2e]
weight = 2
uses = { e2e-db = 1, memory = 4 }
stop_signal = "SIGINT"
cmd = ["npx", "playwright", "test"]

[tasks.lint]
weight = 1
uses = { memory = 11 }
cmd = ["npx", "eslint", "."]
  • capacity limits a resource on the whole machine. The machine config can set or override it; with no capacity anywhere, the resource is not limited machine-wide.
  • per_checkout limits what the jobs of one checkout use together, for what a checkout has only one of: its dev server port, its local database, its build output. A checkout is a directory with .loadmaster.toml, so each worktree is one. A port that every worktree shares is a resource with capacity = 1 instead.
  • Every name in uses must be declared under [resources]. Names are shared by all repositories on the machine, like the capacity, so give project-specific resources project-specific names.
  • An amount above a limit counts as the whole limit, like a weight above the capacity.
  • To keep runs from overlapping, such as a timing-sensitive test that must run without another of its kind, give them a resource with capacity = 1. Don't give the task a weight at or above the capacity for that: it would also keep every other check off the machine, and while it waits, every job behind it waits too.

A job waiting for a resource keeps out only what would be in its way when it starts: a lint that needs no database can start while an e2e run waits for one, as long as it will still fit beside the e2e run then. See How it works.

A job that waits for what a running task holds can only start once that task ends, so the checks behind it need only fit beside the task now and beside the waiting job at its start, without the task. A unit run waiting for the browser a long UI capture holds keeps nothing from the checks behind it that fit beside the capture, and a second capture waiting for the first keeps nothing either. A waiting run with more cores than the task it waits for keeps out the checks that fit beside that task but not beside it: loadmaster check points out the tasks whose weights do that on this machine, and loadmaster status shows what each waiting job would not fit beside.

Make manual operations on a resource tasks too, such as resetting the e2e database by hand: uses = { e2e-db = 1 } then makes them wait for the runs using it, and keeps new runs off it until they are done.

The running task gets two environment variables:

  • LOADMASTER_WEIGHT: the cores the task got: its weight, or, for a task with min_weight, as many of them as fit when it started, so the task can size its own parallelism to match the slot it holds.
  • LOADMASTER_HELD: the ID of the job holding the slot (see Nested runs and Guarding a script).

It also gets LOADMASTER_STATE_DIR, and LOADMASTER_CONFIG if that was set, as absolute paths, so a loadmaster it runs finds the same queue and machine config, though the task runs in the directory of .loadmaster.toml.

If you need shell features, for example to use LOADMASTER_WEIGHT, call a shell explicitly. The trailing "sh" becomes $0, so the extra arguments arrive as "$@":

[tasks.test]
weight = 4
cmd = ["sh", "-c", "exec npx vitest run --maxWorkers=$LOADMASTER_WEIGHT \"$@\"", "sh"]

One check per task

Make each check a task of its own, and run a series of checks, such as a "verify everything" script, as one loadmaster run per check rather than as one task. A job holds its weight for as long as it runs: a series defined as one task needs a weight that covers its heaviest step and holds it through every step, and while several such series wait in line, every check behind them waits for all of them. Run one by one, each check holds only its own weight, and checks that other agents started in the meantime run between the steps:

#!/bin/sh
set -e
export LOADMASTER_MAX_WAIT=  # no limit for the steps; see below
loadmaster run lint
loadmaster run test
loadmaster run e2e

This is a trade-off, made on purpose: a series gets back in line for each step, so it finishes later than if it held the machine throughout, and in exchange the short checks that agents run while they iterate don't wait behind it.

The steps of a script should not give up waiting: exit code 75 would stop the script like any other failure, and running it again would repeat the steps that already passed. Set LOADMASTER_MAX_WAIT to an empty string for them, as above, and give the script itself a timeout that covers the waits. An agent that needs to keep its place across tool timeouts can run the checks one command at a time instead.

Not every check has to be a task. A quick check queued behind a heavy one that waits for cores waits too, even with cores free, unless it will fit beside the heavy one when that starts; that is what keeps heavy jobs from starving. A check that keeps a core busy for a few seconds, such as a single test file, can instead run outside the queue and never wait. Leave room for such checks in the capacity (the default keeps 2 cores out of it).

Machine: ~/.config/loadmaster/config.toml

capacity = 8

[resources]
memory = 48  # GiB
e2e-db = 3   # overrides the repository's default

If capacity is not set, it defaults to the number of CPU cores minus 2 (at least 1). The capacity only counts work that goes through loadmaster; editors, dev servers, Docker and the agents themselves still need room. On Apple Silicon the core count includes efficiency cores; the number of performance cores (sysctl hw.perflevel0.physicalcpu) is often a better starting point.

measure_only = true makes every job start at once, whatever the capacity and its place in line, and only records it, to get a baseline; see Checking that it helps.

While CPU is busy outside the queue (a game, a video call, a build that does not go through loadmaster) beyond the cores the capacity leaves free, jobs start with fewer cores, down to their min_weight; a task without one starts with its weight once nothing runs ahead of it. See How it works. adapt_to_outside_cpu = false turns that off.

$XDG_CONFIG_HOME is honored, and LOADMASTER_CONFIG overrides the path; a file it names must exist, while a missing file in the default place means all defaults. Like .loadmaster.toml, the file can name the oldest loadmaster that reads it with min_version, for a file shared across machines.

The queue lives in ~/.local/state/loadmaster/queue-v4.db ($XDG_STATE_HOME, LOADMASTER_STATE_DIR), and stays there: from 0.12 on every version shares it. The database records the admission protocol its writers keep: versions of one protocol count each other's jobs and may run side by side, and a version that does not know the protocol a later one moved the queue to stops with an error instead of starting work. 0.15 moved it from the protocol of 0.12 to 0.14 to its own, which lets a job start with fewer cores than its weight: the first loadmaster run of 0.15 to get in line closes the queue to new jobs, so that runs of 0.12 to 0.14 fail and say why, and runs of 0.15 wait without a place in line; the jobs in it finish, their own loadmaster admitting them; then the first 0.15 that writes finds it empty and moves it, and the runs of 0.15 get in line (at once, if it was empty). From then on 0.12 to 0.14 cannot use it. Other commands of 0.15 (status, held, cancel, clean) leave such a queue as it is, so trying 0.15 out with them stops nobody; status says what its first run would do. So upgrade every agent on a machine at once, when few checks run. Versions up to 0.11 kept separate files (queue-v3.db and before) and do not count the jobs of later ones, or the other way round: loadmaster status and loadmaster run say when such a file holds jobs. Next to it, history.jsonl gets one line per job (task, weight and the cores it got if fewer, resources and their limits, capacity, its checkout and its repository, wait and run time, CPU time, load average, the machine's CPU busy while it ran, signal, whether loadmaster asked it to stop, exit code and whether it counts as passed), which loadmaster report summarizes, loadmaster tune replays, and status, guide and the lines of a waiting run take usual run times from; it reads only lines of the format this version writes, and says how many others it skipped. If loadmaster is killed before it can write its line, the process that later reclaims the job writes one with outcome lost.

How it works

  • Admission: jobs are judged in line order. A job starts when its weight (or, with min_weight, some of it) and each resource it uses fit beside the running jobs ahead of it now, and will still fit beside every job ahead of it that waits, when that one starts, next to whatever else could be running then; it starts with the most cores that allows. A job decides from the jobs ahead of it alone, so no job is ever delayed, or started with fewer cores, by jobs that got in line after it, and the jobs that start never take the machine past a limit. A small job can start ahead of a big one that waits for cores only where it cannot be in its way; when the big one needs the whole machine, a quick lint waits behind it even while some cores are free. A job waiting for a resource, such as a database stack, lets jobs that don't need it pass, as long as they fit beside it when it starts. Whichever waiting loadmaster first finds that jobs may start starts all of them, in line order, in one transaction; each finds itself started on its next poll.
  • Crashes: state is a per-user SQLite database. Every update first checks that the processes holding places and slots are still alive (comparing PID and process start time, so a reused PID can't fool it):
    • a waiting job disappears from the line once its loadmaster process dies;
    • a running job keeps its slot while its loadmaster process or any process in the task's process group is alive. If only loadmaster is killed, or the task's main process exits but leaves other processes running, the slot stays taken until they have all exited.
    • a task is recorded before it starts, so there is no moment in which it runs untracked.
  • Signals: SIGINT, SIGTERM or SIGHUP while waiting leaves the line. While the task runs, they are forwarded to the task's process group, and loadmaster waits for the task to exit before freeing the slot. A SIGTERM asks loadmaster to stop the task, and reaches it as the task's stop_signal (SIGTERM unless set), as a cancel does: tools differ in the signal they clean up on. It is passed on once, whoever sends it, so a second one cannot cut short the clean-up the first started; SIGINT and SIGHUP pass as they came, each time. SIGKILL (or loadmaster cancel --kill) forces a task to end. Signals that were ignored when loadmaster started (as under nohup) stay ignored. Every process in the group gets the signal, as with Ctrl-C in a terminal, so a wrapper script that forwards signals to its child makes the child get each one twice. Have the wrapper exec the child instead, or write handlers that survive a second signal (a Node.js handler added with process.once does not: the second signal ends the process).
  • Unknown tasks and broken config files fail immediately instead of waiting.
  • A weight above the capacity counts as the whole capacity, and so does a min_weight, with a note on stderr: the jobs that got in line after the task wait until it has started, and it runs alone unless its min_weight lets it start with fewer cores beside other jobs. Weights are committed with the repository, so this keeps them usable on a smaller machine, such as a CI runner.
  • CPU busy outside the queue, such as a game, takes cores from the jobs that start. loadmaster measures how busy the machine's CPUs are, over a quarter of a second at most every 3 seconds, averaged with the measurement before (from the kernel's CPU time, not the load average), and counts what its running jobs were granted as the queue's own. The rest includes what a job uses beyond what it got, which the measurement cannot tell from load outside the queue: loadmaster status shows the CPU busy beyond what running jobs got, and the CORES column of loadmaster report shows which tasks use more than their weight. When the rest is more than the cores the capacity leaves free, a job with min_weight starts with that many fewer cores, but never fewer than its min_weight; a job without one starts with its weight. So the first job in line still starts once the jobs ahead of it are done. The task learns the cores it got from LOADMASTER_WEIGHT, and loadmaster run says so on stderr; a task that sizes its workers by it (--maxWorkers=$LOADMASTER_WEIGHT) runs fewer of them rather than crowding a busy machine into timeouts. A task that runs keeps what it started with. When a task that ran for 3 s or more fails or is killed while the CPUs were 95% busy or more, loadmaster run notes it on stderr, since a check slowed that much can run into a timeout, and loadmaster report counts such checks. loadmaster cannot tell why a check failed, so it does not retry it or change weights. But it knows the run would start into the same load: the next run of that task from that checkout, within 30 minutes, waits before getting in line until the CPU outside the queue leaves it its weight, at most 10 minutes (and within --max-wait), and the note's loadmaster: next: line says so.
  • Why a job waits is worked out by the admission rule itself, which loadmaster status and the waiting messages report: a dimension in use by the running jobs ahead (or those about to start), or one it would not fit beside a job ahead when that one starts, with the jobs that cause it.

Nested runs

If a task itself calls loadmaster run, the inner call must not queue: it would wait forever for the slot its own parent holds. The inner call sees LOADMASTER_HELD and runs directly in that job's slot, without taking another one, so it has to fit there:

  • the job must be running and must have been started from the same checkout, so a stale, made-up or leaked LOADMASTER_HELD is not a free pass;
  • the inner task's weight must not exceed the cores the outer task got (its weight, unless it started with fewer; an inner task with min_weight fits if that does, and gets the outer task's LOADMASTER_WEIGHT), and every resource it uses must be in the outer task's uses, with at least the same amount.

Otherwise the inner call fails with exit code 125 and says what to change. Give the outer task a weight and uses that cover everything it runs. Nested runs keep a task that happens to call loadmaster, such as a script that is also run on its own, from deadlocking; to run several checks in a row, prefer one loadmaster run per check.

Giving up without losing your place

loadmaster run --max-wait 600 test

If the job has not started after 600 seconds, loadmaster exits with code 75 but keeps the job's place in line for 120 seconds. Running the same command again from the same directory resumes that place instead of joining the back of the line. "The same command" means the same caller directory, task name and extra arguments; if the task's weight or resources changed in the meantime, the re-run gets in line again instead. Jobs behind a kept place still leave room for it, as if it were waiting, until it is resumed or expires. The 120 seconds are fixed, and each resumed run may again wait up to --max-wait.

LOADMASTER_MAX_WAIT sets the default for --max-wait; set to an empty string, it means no limit. You can tell loadmaster's 75 from a task that happens to exit with 75 by its last line on stderr: ... gave up after waiting ..., followed by the loadmaster: next: line with the command to run again.

Commands

loadmaster guide
loadmaster run [--max-wait SECONDS] <TASK> [-- ARGS...]
loadmaster status [--all] [--json]
loadmaster cancel [--kill] (<ID>... | --here)
loadmaster doctor [--json]
loadmaster check [--scenario FILE]
loadmaster tune [--since TIME] [--until TIME] [--set TASK.KEY=VALUE]... [--json]
loadmaster report [--since TIME] [--until TIME] [--dir TEXT] [--args TEXT] [--json]
loadmaster clean
loadmaster held [--uses RESOURCE]

Their users are AI agents: every line is meant to be acted on as it is written, briefly, in the same words each time.

  • guide: how an agent runs checks here, from the binary itself, so it matches the version installed: the rules (run checks only through loadmaster, one command per call, waiting is normal, what each way a run ends means and what to do), the machine's capacity and mode, and this repository's tasks, one line each, with their weight, the cores they may start with, resources, the exit codes that pass, their usual run time and their description. Without a .loadmaster.toml, or with a broken one, it still says the rules, and how to set one up or what to fix. An instruction file needs one line: run loadmaster guide and follow it.

  • run: run a task once its weight and resources fit. What it says on stderr, every line starting with loadmaster: and naming the job #ID task``:

    • #42 test started when the task starts (with the cores if fewer than its weight, and how long it waited);
    • #42 testis waiting (weight 4, 1 job ahead) for 4 cores (8 of 8 in use); running: #41e2e for 1m02s (usually 3m10s) when it starts to wait. Off a terminal, where an agent's tool reads the lines once the command has ended, it then only says #42 test has waited 4m00s every 4 minutes, since some tools end a command that has said nothing for a while; loadmaster status says what it waits for now. On a terminal it says so again when what it waits for or who holds it changes (at most every 15 s), and at least every 60 s;
    • #42 test passed in 2m03s (exit 0; waited 12s) when it ends: passed, failed, stopped as asked, was killed by SIG..., left the line without running or could not start, with the exit code, so that a task's own exit code of 75 or 125 is not taken for loadmaster's;
    • #42 test gave up after waiting 10m00s ... (exit 75), followed by loadmaster: next: run loadmaster run test -- a.ts again from /repo within 120s: it resumes its place in line;
    • loadmaster: error: ... (exit 125), followed by loadmaster: next: ... where there is one thing to do.

    LOADMASTER_QUIET=1 leaves out the lines that only inform (started, waiting, ended), so the task's stderr passes through as it is; what asks for action is always said. It is for scripts, not agents: a tool that ends a command silent for a while (Gemini CLI's shell, after 5 minutes) would end a quiet one that waits that long.

  • status: the capacity and what is in use, resources, the load average, the CPU busy beyond what running jobs got, jobs in the queue of a version up to 0.11, then one line per job in line order: its state, task, cores or weight, for how long (and how long it usually runs), where from (this checkout for the checkout status is run from), and why: what it waits for and the jobs in its way (those that hold it, cores included, and the job ahead it would not fit beside when that one starts), how long a parked place is still kept, what holds the slot of a job whose command exited. Run in a checkout, it lists that checkout's jobs and, for those that wait, the jobs ahead of them that have not started and the jobs in their way, these without why they wait themselves; the rest, neither ahead of them nor in their way, is counted in a line. --all, or run outside any checkout, it lists every job. Then ended lately, from this checkout: the checkout's checks of the last 30 minutes with their result and exit code, for a run whose output was lost (a tool that timed out, say). --json prints it for tools, with every job; see Status as JSON.

  • cancel: stop jobs, by ID or (--here) every job started from this checkout except the one cancel itself runs in, and wait until they have left the queue. It leaves a request in the queue, and the job's own loadmaster acts on it within a fraction of a second: a waiting job leaves the line, and a running job's task gets its stop signal (SIGTERM unless the task sets stop_signal), once, as described under Signals, with the slot freed once it exits. That needs no permission to signal another agent's processes, which sandboxes do not give. A job whose loadmaster is stopped (Ctrl-Z) is continued so that it can act on it, and one whose loadmaster is gone has its task signalled directly, where that is allowed. Canceling a job again asks for nothing more unless it adds --kill. A kept place (exit code 75) is dropped. --kill asks for SIGKILL instead, for a task that does not stop; it gets no chance to clean up. loadmaster report leaves canceled checks out. Exits with 0 once the jobs are gone.

  • doctor: whether loadmaster works for agents here, and what to change where it does not: this binary against the one on PATH, the configs, LOADMASTER_MAX_WAIT, whether this process may write to the state directory (it creates and removes a file there: run by an agent's tool, this is the sure test of its sandbox), the queue file as it is (read, never created or moved), other versions' queues, Claude Code's settings (its sandbox's allowWrite across the user, project, local and managed files, its Bash timeout against LOADMASTER_MAX_WAIT), Codex's config (its sandbox mode, writable roots or permission profile), and whether the repository's AGENTS.md or CLAUDE.md tells agents of loadmaster, unless its own commands already go through it (loadmaster run or loadmaster held in package.json, a Makefile, a justfile, a Taskfile, mise.toml or deno.json): an agent that runs those needs no instruction. Each finding is problem (something this process could not do; exit code 1), note (something a settings file suggests; settings have layers and defaults a file does not show), unknown or ok, with the fix: the file and the snippet. It changes no settings itself. --json for tools.

  • check: validate .loadmaster.toml and the machine config, list the tasks and resources with their limits on this machine, say how many cores the capacity leaves for CPU busy outside the queue, and warn about weights that take the whole machine or crowd out other tasks while waiting for a resource. It does not touch the queue. Exits with 0 if the configs are valid, warnings or not, and with 125 if not. --scenario FILE instead judges the queue a file describes, as status --json writes it (- for stdin), by the same admission rule, with the CPU busy outside the queue the file gives: capture a real queue, change what you want to try (the CPU outside, a job's weight), and see what would start and why the rest wait. It reads no config and no queue; the jobs' states are taken as they are. Paths are compared as status --json writes them, so two checkouts whose paths differ only in bytes that are not UTF-8 count as one.

  • tune: what other settings of this repository's tasks would have done to this machine's checks, replayed through the admission rule itself: with the current settings, with measured weights (each the 90th percentile of the cores its passed runs kept busy, rounded up), and with your own --set TASK.weight=N or --set TASK.min_weight=N (or none). It compares their waits, their times from getting in line to the end, and the share of the time more cores were busy than the capacity (the cost of a weight below what a task uses), and prints the change to try. A scenario, not a promise, and it says so with every assumption: checks arrive when they did, no CPU is busy outside the queue, a task with min_weight runs longer on fewer cores as its CPU work would and others as long as they ran, more cores shorten nothing, the share over capacity is an estimate from averages (not a bound: checks peak at other times than their averages say), the machine has its capacity of now, measure-only checks are replayed as admitted, and resources whose limits a record lacks are unlimited. Only this repository's tasks (from any of its worktrees) change; every other check is replayed as it recorded. It says how close its replay of the current settings comes to the waits recorded, and suggests no change of a resource or of min_weight on its own. --since/--until as for report; --json for tools.

  • report: summarize the history log per task. --since and --until limit it to checks that got in line in a period, given as a time ago (12h, 7d, 2w) or a local time (2026-10-12T16:44, 2026-10-12), for example around a change of settings; --dir myapp keeps checks run from directories whose path contains myapp. --json gives the same as data: each section (mode, capacity, checks, the demand and use over capacity, the load average) with its tasks, one per setting, with their counts, cores kept busy, over_weight, over_grant, and run and wait times. See Checking that it helps.

  • clean: reclaim places and slots whose processes are gone. Every update already does this, so you should rarely need it.

  • held: exit with 0 if this process runs inside a loadmaster job started from this checkout (and, with --uses, using that resource), and with 1 if not; outside any job, saying why and, on a loadmaster: next: line, to run it with loadmaster run instead, and inside a job's process group, leaving that to the job's run, which says it only if the job fails; see Guarding a script. Its own failures exit with 125.

~/src/app-wt3$ loadmaster status
capacity 8 (set in /Users/you/.config/loadmaster/config.toml): 5 in use
`e2e-db`: 1 of 2 in use
load average 22.9 over the last minute, on 14 cores: more than the cores, often from work outside the queue or tasks that use more cores than their weight (see `loadmaster report`); on Linux, processes waiting for disks count too

jobs of this checkout, in line order, with the jobs ahead of them or in their way:
#41 running `test` with 4 cores, for 3.0s (usually 1m10s), from ~/src/app-wt1
#43 waiting `e2e` (weight 2), using `e2e-db`, for 2.0s, from ~/src/app-wt2
#44 waiting `lint` (weight 3), for 1.4s, from this checkout: 1 job ahead; waits for 3 cores (6 of 8 could be in use when #43 `e2e` starts, beside #41 `test`)
1 more job, of other checkouts, keeps none of these waiting (1 running): `loadmaster status --all` lists every job

ended lately, from this checkout:
#40 `lint` passed in 8.1s (exit 0), 2m ago

With --all, the jobs are:

#41 running `test` with 4 cores, for 3.0s (usually 1m10s), from ~/src/app-wt1
#42 running `e2e-smoke` with 1 core, using `e2e-db`, for 2.5s (usually 40s), from ~/src/app-wt2
#43 waiting `e2e` (weight 2), using `e2e-db`, for 2.0s, from ~/src/app-wt2: next in line; waits for `e2e-db` in its checkout (1 of 1 in use by #42 `e2e-smoke`)
#44 waiting `lint` (weight 3), for 1.4s, from this checkout: 1 job ahead; waits for 3 cores (6 of 8 could be in use when #43 `e2e` starts, beside #41 `test`)

Job 43 waits because app-wt2 already has its one e2e-db. Job 44 would fit in the 3 cores free now, but job 43 starts once job 42 ends, maybe while job 41 still runs, and then 6 of the 8 cores are in use: the lint would take the machine past its capacity, so it waits. for is the time spent in the current state. The load average counts everything on the machine; well above the capacity in use, it points to work outside the queue, here a build run directly.

Status as JSON

loadmaster status --json prints the same as one JSON object, for tools, with every job wherever it is run from. Its format is versioned (format); within a version, fields may be added, so ignore the ones you do not know. Any other change is a new version, listed as a breaking change of a release: a field removed or renamed, or its meaning, type or unit changed, and a new value of state, need, reason or result. The text status prints, and every text, may change in any release.

  • format: 2.
  • protocol: the admission protocol of the queue: 1 for one that 0.12 to 0.14 made, until a run of 0.15 or later has moved it, even while empty (the waits of their jobs are their own loadmaster's to explain: wait is null); 2 once moved, or for a queue made by 0.15 or later; and moving: once such a run has started the move, until it is done, {left}, the jobs of 0.12 to 0.14 still in it, else null.
  • capacity, capacity_source, measure_only, and cores_in_use by running jobs.
  • cpu: if the CPU busy outside the queue was measured (jobs adapt to it), {cores, busy_beyond_running_jobs, start_limit}: the machine's CPUs, the cores busy beyond what running jobs got, as measured, and the most cores a job of this machine's capacity starts with now (one whose min_weight is more starts with that); else null.
  • load_average over the last minute, or null.
  • resources: {name, in_use, capacity} for each machine-wide resource a job uses (capacity is null if nothing limits it).
  • other_queues: {file, readable, running, waiting, parked} for each queue of a version up to 0.11 that holds jobs or could not be read: the tasks of its jobs that run, wait, or keep a parked place they may resume (null where the file could not be read).
  • reclaimed: how many jobs whose processes were gone were reclaimed.
  • recent: the checks of the checkout status was run from that ended in the last 30 minutes, latest first (at most 5; none if it was not run from a checkout), each {id, task, result, exit_code, run_ms, wait_ms, ended_at_ms}; result is passed, failed, killed (by a signal or after its stop signal), left_line (never ran), not_run (could not start) or canceled.
  • jobs, in line order, each with:
    • id, task, weight, min_weight (the fewest cores it may start with: its weight unless the task sets one), cores (what it started with, once it runs, else null), uses (for each resource, {amount, capacity, per_checkout} as the job got in line with them, the limits null where it had none), dir and checkout (bytes that are not UTF-8 replaced), args, pid (of its loadmaster) and pgid (of its task), or null; capacity, adapts_to_outside_cpu, measure_only;
    • same_checkout: whether it was started from the checkout status was run from (other agents may work in it too);
    • usual_run: how long its task, set up so and run with the same arguments, usually runs, from the history: {median_ms, runs, latest_at_ms} (the median of how many passed runs, and when the latest ended), or null with fewer than 3. A median, not what is left of a running one;
    • state: running; waiting; parked (gave up waiting, keeps its place for a re-run); orphaned (its loadmaster is gone, its task still runs); or lingering (its task's command exited, and processes it left in its process group hold the slot);
    • state_since_ms: when it entered that state (milliseconds since the Unix epoch), as the text's for counts from: when it got in line or resumed its place, parked, started, or was handed over to its task's process group. null where that was not seen, as for a job left orphaned by a loadmaster that died, or one waiting from before 0.14. Also enqueued_at_ms (when it first got in line), started_at_ms and parked_at_ms, or null;
    • processes: for a lingering job, {pid, name} of each process in its group; else null;
    • wait: null for a job that runs. For one that waits or is parked, {starts_now, cores, ahead, for, text}: starts_now is true if the rule lets it start now (the next poll of a waiting job's loadmaster starts it, with every other that may; a parked one when resumed), cores the cores it starts with then (else null), ahead is the number of jobs ahead of it that have not started (null with starts_now), text the reason in words, and for what it waits for (on cores, its min_weight), each with:
      • need (cores, resource or checkout_resource), resource (its name, or null) and amount;
      • reason: in_use, if the jobs ahead that run or are about to start, held_by (IDs), use used of limit (cpu_outside is {busy, cores} if CPU outside the queue lowered the limit, else null); or beside_start, if used of limit could be in use when job start starts, beside being IDs of jobs that could run beside it then, or null when the amount is only a bound;
      • limit_set_by: the ID of the job ahead whose record set a limit smaller than this job's own, or null.

Format 1 (0.12 and 0.13) had no state_since_ms, rounded busy_beyond_running_jobs to a tenth, left cpu out when the machine config turned adapting off, and gave other_queues as {file, running}.

Exit codes

Code Meaning
task's code The task ran and exited normally.
128 + n The task was killed by signal n, or loadmaster was interrupted by signal n while waiting.
75 --max-wait elapsed. The job keeps its place in line; run the same command again to resume.
125 loadmaster itself failed: unknown task, invalid config, a config that needs a newer loadmaster (min_version), no .loadmaster.toml in this repository, invalid LOADMASTER_HELD or LOADMASTER_MAX_WAIT, a nested task that does not fit in its holder's slot, bad arguments.
126 The task's program could not be executed.
127 The task's program was not found.

A task can exit with 75, 125, 126 or 127 itself; loadmaster's own messages on stderr start with loadmaster:.

Routing existing commands through loadmaster

Agents and people run whatever the repository offers: npm test, a Makefile target, a script under scripts/. Point those entry points at loadmaster, and every run goes through the queue, whoever starts it and whatever the agent, without relying on permission rules or hooks:

{
  "scripts": {
    "lint": "loadmaster run lint --",
    "test": "loadmaster run test --",
    "test:e2e": "loadmaster run e2e --"
  }
}

The trailing -- lets npm test -- src/a.test.ts pass its arguments on to the task. The tasks' cmd must call the tools themselves (["npx", "vitest", "run"]), never these scripts: a task whose cmd is ["npm", "test"] calls itself forever, because the inner loadmaster run test runs directly inside the outer one's slot (see Nested runs).

Guarding a script

A script that must only run through the queue, such as one that starts an e2e database, can check that it does:

loadmaster held || exit 1

loadmaster held succeeds when LOADMASTER_HELD, which every task gets, names a running job that was started from the same checkout (the directory with .loadmaster.toml); nested runs count. Otherwise it exits with 1. Run outside any job, it says why on stderr, including how to run the task through loadmaster. Inside a running job's process group (a test that checks a guard refuses, say), the answer is the script's: it says nothing then, and the job's run says why at its end only if the job fails. This works whatever starts the script, unlike permission rules and hooks, which only see an agent's own commands. A stale or made-up LOADMASTER_HELD, or one from another checkout, fails; one copied into another shell of the same checkout still passes while its job runs, so this too is a guardrail against accidents, not a security boundary. loadmaster held --uses e2e-db also requires the job to use e2e-db, for a script that needs that resource held.

Tools you don't control, such as npx vitest run directly, still bypass the queue; hooks in Claude Code and Codex catch those.

Where loadmaster must be installed

Scripts that go through loadmaster need it wherever they run: on every developer's machine, in CI, in containers and in agent sandboxes. Either route only the scripts that agents use and let CI call the tools directly, or install loadmaster in CI too (a prebuilt binary or mise). On a small CI runner, a weight or min_weight above the capacity simply counts as all of it. An agent that sandboxes its commands must also be allowed to write to the state directory, or loadmaster fails with exit code 125 (see the sandbox settings).

Switching over with existing worktrees

Each worktree runs the scripts of its own branch. Until the loadmaster setup has reached every branch you work on, worktrees on older branches keep running checks the old way, and loadmaster cannot see that load. Merge the setup into those branches (or rebase them) before relying on it. If they used another coordination mechanism, such as a lock file, keep taking that lock inside the new tasks' cmd while old worktrees remain, so that both sides still see each other; remove it once the last old worktree is gone.

Using it with AI coding tools

loadmaster is an ordinary command, so any agent that runs shell commands can use it: Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, Aider and the rest. What differs between tools is how you tell the agents, whether their sandbox lets loadmaster write its queue, how long they let a command run, and how they can be kept from running checks directly. The steps below cover each, with ready-made settings for Claude Code and Codex, which both have been checked with real sessions; for other tools, look for the same four settings.

1. Tell the agents

Put this line in the instructions file your tools read: AGENTS.md, which Codex and many other tools read, CLAUDE.md for Claude Code, or both:

Run checks only through loadmaster: before running any, run `loadmaster guide` and follow it.

loadmaster guide says the rest, for the version installed and this repository's tasks (give them a description): an instruction file that copied the rules would drift from them. Among them, "one command per call" matters: agents like to append ; echo "exit=$?", and a chained command no longer matches allow rules such as Claude Code's Bash(loadmaster run *), so it stops for approval.

Then run loadmaster doctor the way an agent would, from its tool: it checks the next two steps (whether the tool's sandbox lets loadmaster write the state directory, and whether its timeout leaves room for the wait) and prints what to change.

2. Let it write the state directory

Tools that run commands in a sandbox (Codex by default, Claude Code when its sandbox is on, and others) must let them write to loadmaster's state directory, ~/.local/state/loadmaster (or $XDG_STATE_HOME/loadmaster, or LOADMASTER_STATE_DIR). Until they may, the commands that use the queue (run, status, cancel, held, clean) fail with exit code 125 and say that they cannot write there. Nothing else needs to be allowed: inside such a sandbox, loadmaster still sees and reclaims the jobs of other agents, and loadmaster cancel works although the sandbox does not let one command signal processes that another started (it leaves a request in the queue that the job's own loadmaster acts on).

All agents must share one state directory. An agent in a container or a VM with its own home directory is not coordinated with the others unless the directory is shared with it.

Claude Code, in ~/.claude/settings.json:

{
  "sandbox": {
    "filesystem": {
      "allowWrite": ["~/.local/state/loadmaster"]
    }
  }
}

Codex, in ~/.codex/config.toml, with the absolute path of your home directory:

sandbox_mode = "workspace-write"

[sandbox_workspace_write]
writable_roots = ["/Users/you/.local/state/loadmaster"]

With Codex's newer permission profiles instead (configure one or the other, not both: a sandbox_mode anywhere makes Codex ignore profiles):

default_permissions = "loadmaster"

[permissions.loadmaster]
extends = ":workspace"

[permissions.loadmaster.filesystem]
"~/.local/state/loadmaster" = "write"

3. Make room for the wait

A command that waits in line takes longer than the check alone. If a tool kills commands after a fixed time, raise that limit, and set LOADMASTER_MAX_WAIT below it, so that loadmaster gives up first (with exit code 75, keeping the place) instead of being killed.

Claude Code's Bash tool timeout includes the time spent waiting. In ~/.claude/settings.json:

{
  "env": {
    "BASH_DEFAULT_TIMEOUT_MS": "1800000",
    "BASH_MAX_TIMEOUT_MS": "3600000",
    "LOADMASTER_MAX_WAIT": "600"
  }
}

Here a call can wait up to 10 minutes and still leave 20 minutes for the task itself under the default 30-minute timeout.

Codex keeps a long command running while the agent waits for it: in Codex 0.162, a loadmaster run that waited 2 minutes 24 seconds in line ran to the end. Set LOADMASTER_MAX_WAIT in the environment Codex starts from if an agent should get control back sooner; agents re-run on exit code 75 as told in step 1.

4. Keep checks in the queue

Instructions are not enough: agents also run the tools directly. The most reliable fix works for every tool: route the repository's own entry points through loadmaster, and guard the scripts that must not run outside it. Tools can also block direct runs themselves.

Claude Code: deny the direct commands and allow loadmaster, in .claude/settings.json or ~/.claude/settings.json:

{
  "permissions": {
    "allow": [
      "Bash(loadmaster run *)",
      "Bash(loadmaster status)",
      "Bash(loadmaster status *)",
      "Bash(loadmaster cancel *)"
    ],
    "deny": ["Bash(npx vitest *)", "Bash(npx playwright *)", "Bash(npx biome *)"]
  }
}

Claude Code ignores permissions.allow in a project's .claude/settings.json until that workspace is trusted, and every git worktree is a workspace of its own: open each worktree interactively once and accept the trust dialog (for headless claude -p runs, --allowedTools works regardless).

Deny rules also match commands inside && chains and subshells, but they match text: npm test, npx -y vitest or ./node_modules/.bin/vitest still get through. A PreToolUse hook catches those too, and the same script works in Claude Code and Codex, which both pass the command to it as tool_input.command and block the call on exit code 2. Save it in the repository (as .claude/hooks/require-loadmaster.sh, or .codex/hooks/require-loadmaster.sh) and make it executable; it needs jq:

#!/usr/bin/env bash
# PreToolUse hook for Claude Code and Codex: blocks lint/test/e2e commands that bypass loadmaster.
cmd=$(jq -r '.tool_input.command // empty')

# Commands that must go through loadmaster. Adjust to your project.
block='(^|[^[:alnum:]_.-])(vitest|playwright|biome|jest)([^[:alnum:]_./-]|$)'
block+='|(^|[^[:alnum:]_.-])(npm|pnpm|yarn|bun)( run)? (test|lint|e2e)([^[:alnum:]_:-]|$)'
# A single loadmaster command, with nothing chained to it.
allow='^loadmaster (run|status|cancel|check|clean)( |$)'
chain='[;&|`]|[$][(]|[[:cntrl:]]'

if [[ $cmd =~ $block ]] && ! { [[ $cmd =~ $allow ]] && ! [[ $cmd =~ $chain ]]; }; then
  echo "Run this through loadmaster instead (e.g. \`loadmaster run test\`); see .loadmaster.toml for the tasks." >&2
  exit 2
fi
exit 0

Register it for Claude Code in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-loadmaster.sh" }
        ]
      }
    ]
  }
}

and for Codex in .codex/config.toml:

[[hooks.PreToolUse]]
matcher = "^Bash$"

[[hooks.PreToolUse.hooks]]
type = "command"
command = '"$(git rev-parse --show-toplevel)/.codex/hooks/require-loadmaster.sh"'
timeout = 30

Codex loads a project's .codex/config.toml only once the project is trusted, and runs a hook only after you have reviewed and trusted it with /hooks (again after each change to it).

Exit code 2 blocks the call and shows the message to the agent. The hook blocks npm test, cd x && npx vitest and loadmaster run test && npx vitest, and allows a plain loadmaster run test. Its patterns are plain text matches, so adjust them to your scripts: as written, they miss npm run test:unit, and they also block harmless commands such as grep vitest. If your package scripts already go through loadmaster, drop the second block line. Without jq the hook lets everything through. Like permission rules, it is a guardrail against accidents, not a security boundary.

Checking that it helps

loadmaster trades waiting for fewer overloaded runs. Whether that pays off depends on your checks and on how often agents run them at once, so measure it on your machine:

  1. Record a baseline: add measure_only = true to ~/.config/loadmaster/config.toml and work as usual for a few days. Every run still goes through loadmaster and is logged, but none waits, so the machine is as loaded as it would be without loadmaster.
  2. Remove the line and work as usual for about as long.
  3. Run loadmaster report.

The report has one section per setting (measure-only or admission, and the capacity), and one row per task and setting: a task run with another weight, min_weight (shown as 4 (from 1)) or other resources, as by an old branch during a switch-over, gets a row of its own. Tasks are grouped by name across all repositories; if two projects both have a test task, look at one at a time with --dir; to see the runs of a task given some argument, such as a flag you tried, use --args. To compare the time before and after a change, use --until and --since with the time of the change. Checks stopped with loadmaster cancel are left out and only counted: they were stopped on purpose. This report comes from a short demo, five agents each running test and then e2e at once:

$ loadmaster report
history: /Users/you/.local/state/loadmaster/history.jsonl

== measure-only (capacity 12): 10 check(s), 2026-10-11 11:40 to 2026-10-11 11:40
weight in use: above capacity 35% of the time checks ran, peak 20
load average when tasks ended: median 10.7, p90 16.3 (14 CPUs)

TASK  WEIGHT  USES  CORES MED  CORES P90  OVER  CHECKS  PASSED  FAILED  KILLED  LEFT LINE  NOT RUN  RUN MED  RUN P90  WAIT MED  WAIT P90  WAIT MAX
e2e        2     -        1.3        1.4     0       5       5       0       0          0        0      24s      25s      0.0s      0.0s      0.0s
test       4     -        2.2        2.2     0       5       5       0       0          0        0      13s      13s      0.1s      0.2s      0.2s

== admission (capacity 12): 10 check(s), 2026-10-11 11:41 to 2026-10-11 11:42
demand: above capacity 23% of the time checks ran, peak 20 (held back by loadmaster; in use peaked at 12)
load average when tasks ended: median 13.0, p90 17.0 (14 CPUs)

TASK  WEIGHT  USES  CORES MED  CORES P90  OVER  CHECKS  PASSED  FAILED  KILLED  LEFT LINE  NOT RUN  RUN MED  RUN P90  WAIT MED  WAIT P90  WAIT MAX
e2e        2     -        1.3        1.3     0       5       5       0       0          0        0      24s      25s      0.2s       10s       10s
test       4     -        2.9        2.9     0       5       5       0       0          0        0      10s      10s      0.1s       10s       10s

What to compare between the two periods:

  • FAILED and KILLED: the false failures and timeouts loadmaster is meant to remove (along with real failures, which both periods have). FAILED counts exit codes not in the task's ok_exit_codes. KILLED means ended by a signal while running, usually a tool timeout, including runs that exited on their own once loadmaster passed them a stop request and runs whose loadmaster was killed while the task ran.
  • LEFT LINE: checks that stopped before they started: a tool timeout or an interrupt while waiting, or a place given up (exit code 75) and not re-run. This is what waiting cost; it should be near 0 in the measure-only period. NOT RUN counts checks that could not start at all, such as a missing program.
  • RUN MED / RUN P90 (passed checks): contention makes runs slower; under admission they should come close to the time a task takes alone.
  • WAIT (from first getting in line to starting, re-runs after exit code 75 included; for checks that never started, until they ended): what admission costs. If it nears your Bash tool timeout, raise the capacity or lower weights.
  • CORES MED and CORES P90 against WEIGHT: the cores a task really kept busy (CPU time divided by run time), for the median run and the busiest tenth of runs. A weight far above them makes jobs wait for nothing. A ! after CORES P90 marks a task whose busiest tenth kept more than 1.2 times its weight busy: those runs use more than their slot accounts for, so the machine can run above its capacity; raise the weight. Read them from the admission period; under contention they read low.
  • OVER: passed checks that kept more than 1.2 times the cores they got busy, each against its own: its weight, or fewer for a task with min_weight. A task that does not size itself by LOADMASTER_WEIGHT shows up here when it got fewer, even if it stays within its weight: take its min_weight out, or make it size itself.
  • above capacity ... of the time checks ran: how often the weights of the checks running at once (measure-only) or asking to run (admission) added up to more than the capacity. It is only as meaningful as the weights are; the load average and the run times show whether the machine really struggled. Near 0% means the agents rarely overlap and loadmaster has little to do.
  • load average: the whole machine's 1-minute load average when tasks ended, including everything outside loadmaster. A rough sign of how sluggish the machine was.

In the demo, admission made test runs faster (13 s to 10 s) at the price of waits of up to 10 s, with no failures either way, and showed that test uses about 3 cores rather than 4. Periods with different kinds of work are not comparable, and a few dozen checks are noisy; compare similar weeks.

Limitations

loadmaster is deliberately small:

  • One machine. Weights are static; there are no priorities, and no measurement of the CPU or memory each task uses.
  • The capacity only accounts for work that goes through loadmaster. CPU busy outside it lowers what jobs start with, but a task that is already running keeps its cores, and GPU and memory used outside the queue are not measured. Route work through the queue where you can.
  • Tasks run without terminal input (stdin is /dev/null when it would be a terminal). A task that opens the terminal itself, for a password prompt or a pager, is stopped by the OS; Ctrl-C still ends it.
  • A task that leaves a daemon running in its own process group holds its slot until the daemon exits: loadmaster warns about it, and loadmaster status lists the job as lingering, with the processes in its group.
  • Processes a task starts in a session of their own (as Playwright starts its webServer) leave the task's process group: they get no signals from loadmaster and hold no slot, and do not always exit with the task. When a Playwright run gets SIGTERM, it exits at once and leaves its webServer running, with loadmaster or without; on SIGINT it shuts it down, which is what stop_signal = "SIGINT" is for. A server left running keeps its port: the next run fails with "already used" if its webServer checks every server the command starts, and otherwise reuses the old one without a word. A webServer command that exits when its stdin closes, as Vite's dev server does, also goes when Playwright does. loadmaster looks for such processes about once a second while the task runs, and just before it sends the task a signal, and when the task exits, warns of those it found still running, with their PIDs; it does not stop them, since some are meant to stay (a build tool's daemon). One whose parent exits on its own between two looks is not found.
  • history.jsonl is never rotated.

Development

cargo test

Besides unit tests, this runs integration tests that start real loadmaster processes: many jobs at once, a big job among a stream of small ones, kill -9 of waiting and running jobs, nested runs, resources, loadmaster held, cancel and check, exit codes and config errors. Design decisions and their reasons are in docs/DECISIONS.md.

A soak test simulates five agents calling loadmaster run the way Claude Code's Bash tool does (timeouts that SIGTERM and then SIGKILL the process group, re-runs after exit code 75, occasional crashes), in two checkouts with two resources besides CPU. It checks from inside the tasks that no limit is ever exceeded, that every task gets through and that nothing is left behind:

LOADMASTER_SOAK_SECS=600 cargo test --release --test soak -- --ignored --nocapture

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

About

Weighted job admission for parallel AI coding agents: one machine-wide queue for lint, tests, builds and e2e, so agents stop overloading the machine.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages