fleet-harness¶
FLT-1 — the universal harness. A LangGraph supervisor that routes
ticket-sized coding work across four vendor CLI agents (codex,
claude, gemini via agy, grok) plus local Ollama, and
keeps working when any single vendor — especially Claude — is down.
Why this exists (outage continuity)¶
A two-day Claude outage in July 2026 froze a fleet whose orchestration lived inside Claude Code. Lesson: the orchestrator that covers a vendor outage must not depend on that vendor. So:
- The supervisor brain is a swappable LangChain chat model whose
default is local Ollama (
qwen3-coder:30bon the gaming PC) — $0, on-LAN, no API key required, no cloud dependency. - Routing is deterministic config (routing.yaml), not model vibes.
- Every seat has an availability probe and an escalation ladder; a dead vendor reroutes work instead of stopping it.
- Acceptance test: mark Claude down, and a ticket still flows
route → implement (codex) → blind review (grok) → validate → human gate.
Language note: this repo is an approved deviation from the fleet's C#-first policy. Reasons: LangGraph/Deep Agents are the ecosystem standard for this exact problem, the skills are career-relevant, and the outage-continuity property argues for maximum-ecosystem tooling.
Architecture¶
flowchart LR
subgraph supervisor["fleet-harness (Python · LangGraph)"]
R["route<br/>deterministic keywords<br/>+ optional local-LLM refine"] --> I["implement<br/>isolated git worktree<br/>escalation ladder"]
I --> V1["review<br/>BLIND: spec + diff + test claims<br/>DIFFERENT provider · FLT-4<br/>read-only sandbox cwd"]
V1 --> V2["validate<br/>reruns tests itself<br/>never trusts claims · FLT-3"]
V2 --> H{{"human gate<br/>LangGraph interrupt"}}
R --> A["advise<br/>text-only roles<br/>no worktree, no tests"]
A --> H
R -. all seats down .-> H
I -. all implementers failed,<br/>or agent asked for a human .-> H
end
subgraph seats["agent seats (FLT-2 contract) · grouped by CAPACITY POOL"]
C1["codex<br/><i>pool: codex</i>"]
C2["claude<br/><i>pool: native-claude</i>"]
C3["gemini-flash · gemini-pro-high<br/><i>pool: agy-gemini (SHARED)</i>"]
C4["grok<br/><i>pool: grok</i>"]
C6["agy-gpt-oss<br/><i>pool: agy-gpt-oss (scarce)</i>"]
C7["agy-claude<br/><i>pool: agy-claude · RESERVE</i>"]
C5["ollama<br/><i>pool: local · advisory</i>"]
end
I --> C1 & C2 & C3 & C4 & C6
V1 --> C1 & C2 & C3 & C4 & C6
R --> C5
H -. "--break-glass only" .-> C7
Y[("routing.yaml<br/>FLT-6 · one table,<br/>three readers")] --> R
B[("brain: ChatOllama default<br/>FLEET_BRAIN swaps provider")] --> R
Every seat returns one uniform JSON shape (FLT-2 contract,
docs/contract.md):
{status, summary, changed_files, tests_attempted, known_risks, needs_human_review}.
The advise branch exists because text-only work (triage, summaries, format
checks) has no diff to isolate, review, or test — and because the local Ollama
seat cannot edit files. Without that split, the "can this agent edit?" filter
would quietly reroute cheap text work to an expensive coding CLI. routing.yaml
names those roles in advisory_roles.
Capacity: the ladder's rungs are not free bandwidth¶
The tempting model — "if this seat is busy, try the next one" — is wrong here, and getting it wrong is expensive:
- Four seats are
agylanes on one Google account, andgemini-flashandgemini-pro-highdraw on the same pool. Throttled Flash does not make Pro-High a fallback; it is the same dead quota wearing a different label. - When an agy pool locks out, its load transfers to Codex/Grok and accelerates their limits. Degradation is correlated, not graceful.
So routing.yaml models pools, and the router spends pools, not seats:
| Rule | Behavior |
|---|---|
A rate_limited seat locks its pool |
every sibling seat on that pool drops out of the run |
After max_pool_lockouts (default 1) non-local pools lock |
the ladder stops and escalates to the human, rather than spending pools whose remaining capacity is unknown |
| One lockout | still escalates — a single 429 is not an outage |
| Local pool | never counts against the budget; it cannot rate-limit us |
agy-claude (reserve, ~1–2 calls/window) |
not on any ladder, unreachable by any classifier; needs --break-glass {native_red,review_gate,council_synth}, which appends it as the last resort and records the reason at the gate |
| Blind review | excludes the implementer's family — agy's Claude lanes are Anthropic seats behind a Google login |
This is heuristic until FLT-15 lands. There is no quota API, so nothing here
can measure how close Codex is to its cap. capacity.CapacityLedger is the
seam a real fleet-global ledger plugs into; the default NullLedger knows only
what the current run observed and answers cap_proximity() → None — unknown,
said out loud, rather than a comforting 0.0. Treating None as "fine" is the
exact bug this design exists to prevent.
Trust boundaries (the interesting part)¶
| Policy | Where enforced |
|---|---|
| FLT-3: never trust agent claims | validation.py reruns tests and records the real exit code; worktree.py measures changed files from git; base.py downgrades a "completed" claim on a nonzero exit |
| FLT-4: blind, cross-vendor review | graph.py review node — the reviewer gets spec + diff + labelled-unverified test claims, never the implementer's narrative, and is always a different provider |
| Reviewers judge, they don't touch | the reviewer runs in a throwaway empty directory, not the implementer's worktree — it is a full editing CLI, and editing the code we just measured would invalidate the diff, the file list, and the validation run. The tree is re-measured afterwards and any drift is reported |
| Isolation | every implement attempt runs in a fresh git worktree; failed attempts are swept so half-edits can't leak into the next agent's run. The freeform Deep Agents mode isolates per tool call the same way |
| Secrets don't ride the diff | the diff is sent to third-party CLIs, so worktree.redact_secrets withholds the contents of secret-shaped files (.env, *.pem, ...) while still showing that they changed; redactions are listed at the human gate |
| Human authority | the graph's only exit is a LangGraph interrupt() approval gate. An agent returning approval_required goes straight there — no further agents or tests run past a control point it raised |
Not a security sandbox. Worktree isolation is a blast-radius control, not a boundary against a hostile agent: the vendor CLIs execute tools with your privileges and can use absolute paths. Ticket text is a prompt-injection surface, so treat tasks as trusted input. Running untrusted tickets needs a real boundary (container / AppContainer) — that is deliberately out of scope for this scaffold.
How it rides the C# rails¶
This harness is the thin Python head on an existing C# body — it replaces no working tool:
- FLT-2 contract:
docs/contract.mdis the canonical schema; a C# implementation (fleet-dispatch run-agent) is being built in parallel. The subprocess wrappers here are shaped so swappingcodex exec ...forfleet-dispatch run-agent ...changes one command line and nothing else. - FLT-6 routing table:
routing.yamlat the repo root is read by this harness and (by design) by usage-governor and fleet-dispatch — one table, three readers, zero policy forks. - Worktree isolation, review gates, and the never-trust rule mirror the fleet's standing C# policies, so work landed through either rail meets the same bar.
Run it¶
# install (Python >= 3.11)
uv sync
# what the router sees
uv run python -m fleet_harness probe
# route one ticket
uv run python -m fleet_harness run `
--task "Add a retry loop to the fetcher and cover it with a test" `
--repo C:\path\to\target-repo `
--ticket FLT-42
# FLT-1 acceptance: prove Claude-outage continuity
uv run python -m fleet_harness run `
--task "Add a retry loop to the fetcher" `
--repo C:\path\to\target-repo `
--ticket FLT-42 `
--simulate-down claude
The run parks at the human gate with a full evidence packet (attempts,
diff stats, review verdict, measured validation result) and asks for
approval; approved work stays on its wt/<ticket>/harness-* branch for
normal landing. --yes auto-approves (CI/demo only), --no-brain
skips the LLM classifier for fully-offline deterministic routing.
Configuration¶
| Env var | Default | Meaning |
|---|---|---|
FLEET_BRAIN |
(unset) | Swap the supervisor brain to any LangChain provider, e.g. anthropic:claude-sonnet-5. Uses that provider's own env auth. Never required. |
FLEET_OLLAMA_URL |
http://10.0.0.7:11434 → localhost fallback |
Ollama server for the default brain + ollama seat |
FLEET_OLLAMA_MODEL |
qwen3-coder:30b |
Local model tag |
--break-glass <reason> spends the agy-claude reserve for one run. Reason drift
toward "convenience" is a policy failure, not a judgement call — the code lands
in the human-gate evidence packet so it stays visible.
Board Sync¶
docs/BOARD.md is the source of truth until Jira passes the board-as-contract
checklist. board-sync mirrors one status transition into the local board and
any configured remote IDs in docs/board-sync.json.
# dry-run first: prints the file and remote operations, writes nothing
uv run python -m fleet_harness board-sync `
--ticket FLT-11 `
--status "In Progress" `
--mover WT-77c4
# write the board plus configured Vikunja/Jira transitions
uv run python -m fleet_harness board-sync `
--ticket FLT-11 `
--status "In Review" `
--mover WT-77c4 `
--write
# retry remotes after a transient API failure; leaves BOARD.md unchanged
uv run python -m fleet_harness board-sync `
--ticket FLT-11 `
--status "In Review" `
--mover WT-77c4 `
--write `
--remotes-only
Remote writes are config-gated. Vikunja needs FLEET_VIKUNJA_URL and
FLEET_VIKUNJA_TOKEN; Jira needs FLEET_JIRA_URL, FLEET_JIRA_EMAIL, and
FLEET_JIRA_TOKEN. The mapping file shape is:
{
"tickets": {
"FLT-11": {
"vikunja": {
"id": "123",
"status": "Backlog",
"transitions": {
"In Progress": { "bucket_id": 456 },
"Done": { "done": true }
}
},
"jira": {
"id": "FLT-11",
"status": "Backlog",
"transitions": {
"In Progress": "21",
"Done": "31"
}
}
}
}
}
Tests¶
uv run pytest -q
The suite runs the real compiled graph against the shipped
routing.yaml with fake vendor CLIs/git (see tests/conftest.py), and
includes the acceptance test test_acceptance_simulate_down_claude.
Layout¶
routing.yaml FLT-6 role table + escalation ladder (shared w/ C#)
docs/contract.md FLT-2 canonical result schema (shared w/ C#)
docs/BOARD.md delivery board (canonical)
src/fleet_harness/
graph.py the supervisor graph (start reading here)
routing.py deterministic router + availability failover
capacity.py pools, correlated-failure budget, FLT-15 ledger seam
agents/ CLI wrappers: codex, claude, agy, grok, ollama
contract.py FLT-2 dataclass + defensive parsing
worktree.py git worktree isolation
validation.py FLT-3 rerun-the-tests gate
supervisor.py freeform Deep Agents mode (secondary)
cli.py python -m fleet_harness
tests/ real graph, fake edges, offline