Skip to main content
FleetWright

ci pypi python licence dependencies

You ask Claude to extract every claim from 400 scanned pages. It spawns eight subagents. All eight start on page one.

And each one decides for itself what "done" means, so you get eight standards on one corpus.

Both failures have the same cause. A subagent has no context: it cannot see the other seven, and it did not read the reasoning you did before spawning it. So it needs two things it can only get by asking.

  1. Which page is mine? Nobody else is on it, and if I die it comes back.
  2. What am I supposed to do with it? The task, what finished looks like, what to hand back, and which skills I need first.

FleetWright is where both live. You define the work once and enqueue the units; every worker claims one and is handed the assignment with it. Nothing collides, nothing guesses, and afterwards you can see what actually happened.

A library, a CLI, an MCP server and a dashboard, in one SQLite file, with no dependencies at all.

The dashboard: a five-stage run, five workers at 72 to 91 percent busy, and the pipeline drawn as nodes and edges

Start here

uv tool install fleetwright
fleetwright install-skill

That writes a skill into .claude/skills/. Now ask Claude in English:

extract every claim from the 400 files in scans/, using 8 agents

Claude reads the skill and does the rest: defines the work, enqueues the units, spawns the workers in one message so they run at once, waits for them, and collects the results. You watch it with:

fleetwright status --who      # who is holding what, right now
fleetwright dashboard         # or the whole picture in a browser

A later session starts with one command:

fleetwright state

It finds the database, says which runs exist, which are still going, what failed, and ends with the literal next command. A new session has no memory of the last one, and this is how it gets the state instead of guessing at it.

If you run the same work often, put it in a file rather than in a shell history, so it is reviewable and diffable:

fleetwright init                 # writes a commented fleetwright.toml
RUN=$(fleetwright start --label "tomus II")
fleetwright apply --run "$RUN"   # registers skills, defines kinds, enqueues

Everything below is what the skill does on your behalf.

The shape of it

import fleetwright as sa
conn = sa.connect("work.db")

# One execution of a fleet. Everything below belongs to it, so afterwards you
# can ask what THIS run did rather than what the database happens to contain.
run = sa.start_run(conn, label="Tomus II extraction")

# What a skill name means, once. A readable file is hashed, so units claimed
# before and after an edit stay tellable apart.
sa.register_skill(conn, "xrad-extraction",
                  source="skills/xrad/SKILL.md", version="1.2")

# What the work IS. This is the part a spawn prompt cannot do: the ninth
# worker, started an hour from now, reads exactly the same thing.
sa.define(conn, "extract",
    instructions="Read $path. Record every claim it makes, quoting verbatim.",
    done_when="every claim on the page is recorded, or you have established "
              "there are none",
    returns='{"claims": <int>, "notes": "<string>"}',
    skills=["xrad-extraction"],
    mcp={"xrad": "xrad serve --db graph.db"})

sa.add(conn, "extract", pages, run=run, meta={"path": "scans/$name.png"})

Now spawn ten agents whose entire prompt is claim work and do what it says. Each one is handed this:

UNIT: p0189   (kind: extract, id: extract:p0189)

WHAT TO DO
Read scans/p0189.png. Record every claim it makes, quoting verbatim.

YOU MUST HAVE
skills:
  - xrad-extraction v1.2 [99cdba1cede56a04]
      from: skills/xrad/SKILL.md
MCP servers: xrad (xrad serve --db graph.db)
If any of these is unavailable, call fail with that as the reason.
Do not improvise a substitute.

DONE WHEN
every claim on the page is recorded, or you have established there are none

HAND BACK
{"claims": <int>, "notes": "<string>"}

Call finish (unit_id=extract:p0189) when done, or fail with a reason.
Do not start any other unit.

You do not write that prompt. fleetwright prompt extract -n 10 generates it from the kind, so the template cannot drift from the work it describes.

In sixty seconds

uvx fleetwright demo
-- 3. three workers claim, and never collide -----------------
   worker-a: page-1, page-2
   worker-b: page-3, page-4
   worker-c: page-5, page-6
   6 units handed out, 6 distinct -- nobody got the same page

-- 4. two finish. the third crashes, holding its work --------
   worker-c: [process dies without reporting anything]

-- 5. its lease expires, and the work comes back -------------
   ...one second later, after the lease expired:
     worker-d picked up page-5  (attempt 2)
   No daemon ran. reclaim() happens on the way into claim().

-- 6. and the dead worker cannot close what it lost ----------
   worker-c calls finish on page-5: False
   worker-d calls finish on page-5: True

A lease, not a lock

This is the only genuinely hard part of the problem, and every other decision follows from it. It is also not new. Leases are Gray and Cheriton, 1989; SQS shipped visibility timeouts in 2006 and Beanstalkd its TTR in 2007; and litequeue already does expiring claims on SQLite, with retry_expired() and a claim id that makes done() return false for a stale holder. If you want a small SQLite queue, use it.

What is here and not there is everything above the queue: a kind that says what the work is so the ninth worker gets the same brief as the first, skills and definitions hashed and pinned per unit so you can tell what any unit was actually told, runs to scope a corpus, and a dashboard. The queue is the boring part, and it should be.

A lock held by a crashed worker is worse than no lock at all. The unit is neither being worked nor available, and nothing in the system can tell a busy worker from a dead one. A lease makes that distinction the passage of time: renew it and you keep the unit, stop renewing and it returns to the pool.

There is no daemon and no cron. reclaim() runs at the top of every claim(), so the next worker asking for work does the cleanup on its way in.

At-least-once, and nothing can do better

Said here rather than in a footnote, because the alternative is you finding out in production:

A worker that is slow rather than dead will have its lease expire, another worker will take the unit, and both will finish it.

No timeout distinguishes those two cases. Two defences, and you want both: heartbeat while you work, so only genuinely stalled units are reclaimed, and make the write at the end unit-scoped, keyed on the unit name, so a duplicate overwrites rather than appends.

That is deliberately weaker than "idempotent, so a unit done twice converges", which is what this used to say and is not true of the example on this page. Two model runs over record every claim it makes, quoting verbatim produce two different, both-plausible extractions. Overwriting keyed on the unit means you get one of them rather than both concatenated; it does not mean you get the same one. For deterministic work convergence is real. For generative work, plan on the two outputs differing, and if which one you keep matters, key on (unit_id, attempt) and choose.

When a lease is lost, finish returns False rather than raising. Handle it. That worker no longer owns the unit and should claim a different one.

Does it hold up under contention

python bench/contention.py 64 5000

Real processes, not threads: the whole question is what SQLite does when N operating-system processes contend for one write lock on one file, and threads in one interpreter would measure almost nothing.

On an 8-core M-series Mac, SQLite 3.50.4:

Workers Units Finished Duplicates SQLITE_BUSY Throughput p50 p95 p99
32 2,000 2,000 0 0 1,513/s 0.3 ms 11 ms 165 ms
64 5,000 5,000 0 0 1,419/s 0.7 ms 48 ms 667 ms

The two zeroes are the point. Duplicates would falsify the single atomic UPDATE that the whole safety argument rests on, and a SQLITE_BUSY reaching a caller would mean busy_timeout had failed to turn contention into waiting.

Read the tail, not the median. At 64 workers the p99 claim takes two thirds of a second, and it gets worse the more workers you add, because they are queueing for one write lock. It does not matter here: a unit is an agent doing work for tens of seconds, so even 667 ms is under 3% of it. It would matter a great deal if your units were milliseconds long, and if they are, this is the wrong tool.

Watching it run

fleetwright dashboard --db work.db          # http://127.0.0.1:8787
fleetwright dashboard --out fleet.html      # a static snapshot

14 left is the same number whether five workers are moving through the queue steadily or three have died and one is stuck on a page it will never finish. Every panel exists to separate those two situations:

Panel The question it answers
Runs what has this fleet ever done, and how much parallelism did it really get
Throughput is it still moving
In flight is anyone stuck, since anything held past 3x the p95 is marked
Jobs what happened to this one unit
Workers and models did one model do these faster, cheaper, or worse
Who held what, when one lane per worker on a time axis: was the fleet saturated, and who sat idle
What caused what the pipeline as nodes and edges, when one stage enqueues the next
Skills in use which version of which skill, and what nobody registered
Could not finish what needs a human

Served from http.server with the CSS and JS inline and the SVG drawn by hand. No framework, no build step, nothing fetched. It opens the database read only (mode=ro, so SQLite refuses a write rather than the code promising not to make one) and a test drives every route against a live run and compares the tables before and after. There is an optional access token, and because there is no TLS the server refuses to bind off loopback unless one is set, refuses a token shorter than 16 characters, and locks out an address after ten wrong guesses.

It also checks the Host header, which is the defence most local tools skip: without it, a page you visit can point its own DNS at 127.0.0.1 and read your fleet, and the browser cannot stop it. For remote access the answer is an ssh tunnel rather than a network bind, and docs/dashboard.md says why.

Several repositories, one dashboard

A project is a database. There is no registry, because a registry means one file becomes the index for the others and moving it breaks the rest.

export FLEETWRIGHT_PROJECTS="$HOME/code/apply-intelligence:$HOME/code/project-kzd"
fleetwright dashboard

Each one appears in the sidebar under the name you call it by, which is its directory rather than its filename: every repository holds the default work.db, so labelling by filename gave one project called work and the rest their absolute paths. A database you deliberately named something else keeps that name.

Where the database lives

This is worth two minutes, because getting it wrong looks exactly like data loss and is not.

export FLEETWRIGHT_DB="$PWD/work.db"    # do this once, per project

work.db is a relative default. Without pinning it, a worker that runs from a subdirectory opens a different, empty database and truthfully reports that there is nothing to do. Searching up the tree covers the common case, the way git finds a repository, and a name close to an existing database is refused as a typo rather than created. But a subagent inherits its parent's environment, so one export is what actually guarantees twenty workers share one queue no matter where each of them runs.

Nothing here deletes rows: there is no DELETE and no DROP in the package. journal_mode is WAL and synchronous is FULL, so a commit is on disk before it returns, and a worker killed with SIGKILL loses nothing it committed.

To copy one, do not use cp:

fleetwright backup work.db.2026-08-08

In WAL mode the most recent commits live in work.db-wal, so copying the one file silently gets a database missing whatever finished last, while being a perfectly valid database of an earlier moment. backup runs VACUUM INTO, which reads a live database without locking out the writers and writes one file with nothing beside it.

From the shell

claim exits 1 with no output when the queue is dry, so a loop ends by itself. Eight workers, no coordinator:

fleetwright add extract --from-file pages.txt --run "$RUN"

for i in $(seq 1 8); do
  ( me="worker-$i"
    while unit=$(fleetwright claim extract --json --lease 1800 --worker "$me"); do
      id=$(echo "$unit" | jq -r '.[0].unit_id')
      name=$(echo "$unit" | jq -r '.[0].name')
      if my-extractor "$name"; then
        fleetwright done "$id" --worker "$me"
      else
        fleetwright fail "$id" --note "extractor exited $?" --worker "$me"
      fi
    done ) &
done
wait
fleetwright status --who

--worker "$me" on the claim and on the close, with the same name. Each of those is a separate process, so there is no identity that carries from one command to the next, and a close that cannot show whose unit it is is refused rather than guessed at. Pass --token from the brief instead if you prefer, or --any-worker when you are cleaning up after a fleet that is gone.

A script driving a fleet does not need to parse any of that. wait blocks until the work is over and the exit code is the interface: 0 finished cleanly, 1 something failed, 2 timed out.

RUN=$(fleetwright start --label "nightly extraction")
fleetwright add extract --from-file pages.txt --run "$RUN"
./spawn-my-workers.sh &

if fleetwright wait --run "$RUN" --timeout 7200; then
  ./collect-results.sh
else
  fleetwright status --run "$RUN"        # something failed, or it stalled
  fleetwright retry --run "$RUN"         # fix the bug, re-run only what broke
fi

From an agent

Fifteen MCP tools, split by who uses them.

A session that has just arrived calls project_state first: which runs exist, which are still going, what failed, and the single next command. You have no memory of the last session and that is how you get it.

The orchestrator then uses start_run, register_skill, define_kind, add_jobs, worker_prompt, job_results, list_runs and list_skills. It can stand up an entire fleet without touching a shell.

Each worker uses claim_job, finish_job, release_job, fail_job, heartbeat_job and job_status.

{"mcpServers": {"work": {"command": "fleetwright",
                         "args": ["serve", "--db", "work.db"]}}}

The tool descriptions carry the protocol, because that is all a worker reads: claim before starting, do what the brief says rather than what you assume, and stop when the queue is empty rather than invent work. That last one is the failure mode worth designing against, since an agent with nothing to do will reliably find something, and what it finds is usually a unit somebody else has.

Documentation

Concepts leases, capabilities, skills, and what is deliberately refused
MCP wiring it to Claude Code, Cursor, or your own agent
Dashboard what each panel answers, and why percentiles not averages
Reference every command and the Python API
Skill the Claude Code skill, and how install-skill works
Packaging uv, Homebrew, pip, and which to use
Licensing Apache-2.0 everywhere except ee/, and where the line is

What it is not

Not a scheduler. No dependencies between units, no backoff, no cron, one integer of priority. If you need those, run a real queue and keep this for the hand out.

Not a broker. One SQLite file on one filesystem. Many processes, one box. SQLite over NFS is not safe and this does not pretend otherwise.

Not exactly once. See above. Nothing is.

It does not spawn agents. fleetwright prompt generates the prompt to spawn them with. Running it belongs to your runtime, because the moment this package spawns anything it needs credentials and an opinion about which agent framework you use.

It does not fetch or install skills, and it cannot verify that a worker loaded one, any more than it can verify the model a worker says it is. Both are declared. The brief states the requirement and says to fail rather than substitute.

Licence

Apache-2.0, including the right to run it, modify it, and sell a service built on it. The ee/ directory is the one exception and is currently empty. LICENSING.md draws the line and argues for it.

Contributions welcome under the DCO, with no CLA. CONTRIBUTING.md says what will and will not be accepted before you spend an evening.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fleetwright-0.25.0.tar.gz (322.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fleetwright-0.25.0-py3-none-any.whl (106.7 kB view details)

Uploaded Python 3

File details

Details for the file fleetwright-0.25.0.tar.gz.

File metadata

  • Download URL: fleetwright-0.25.0.tar.gz
  • Upload date:
  • Size: 322.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for fleetwright-0.25.0.tar.gz
Algorithm Hash digest
SHA256 efb7400522d06e97b11657350578341061e5629ea7f68a14192fba97e8a001dd
MD5 5ea83dd52fe12b1f5f9aef4c23de7e87
BLAKE2b-256 abf4c89ae232aabc5ccd9b56e1cb9e2d4ffefb266b835a54f4dd6b2b26d0d43a

See more details on using hashes here.

Provenance

The following attestation bundles were made for fleetwright-0.25.0.tar.gz:

Publisher: release.yml on narimannemo/fleetwright

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file fleetwright-0.25.0-py3-none-any.whl.

File metadata

  • Download URL: fleetwright-0.25.0-py3-none-any.whl
  • Upload date:
  • Size: 106.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for fleetwright-0.25.0-py3-none-any.whl
Algorithm Hash digest
SHA256 959044d7471b46758509ac166799fdd1d59802fd1658411d2d13012796af176a
MD5 dfe9c480ea46310a74ee395ce7fe951d
BLAKE2b-256 2dcadd1e12ce7fab33ea8e06f293d0307328fe9d60d55ae4d8d067b863028511

See more details on using hashes here.

Provenance

The following attestation bundles were made for fleetwright-0.25.0-py3-none-any.whl:

Publisher: release.yml on narimannemo/fleetwright

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page