Fleet Dispatch — Architecture¶
Technical track. See docs/plain/OVERVIEW.md for the plain-English version and docs/README.md for the full documentation index.
System shape¶
Fleet Dispatch is a per-PC listener service. Every house PC runs fleet-dispatch serve, which
brings up (as concurrently-watched tasks inside one process):
- An HTTP accept loop (
DispatchServer) bound to this PC's LAN IPv4 address (or Tailscale address when present) plus127.0.0.1— servesGET /healthandPOST /dispatch. - A gated queue-drain loop (
QueueService.ListenOnceAsync, polled every--pollseconds) that drains this PC's OneDrive inbox. - An always-on availability presence loop (
AvailabilityDetector, ~4s poll) — see Availability gate and detector. - A repo-registry scan loop (boot pass + daily timer) — see Repo registry scanner.
Task.WhenAny watches all four; a fault in any one tears the process down rather than leaving a
half-dead listener running silently (FLT-71 hardening — the previous code let the listener rot
and kept the drain loop spinning with a dead HTTP endpoint).
The listener runs unprivileged by default. -RunLevel Highest (elevated) is documented as a
last resort in README.md, used only until the URL ACL
reservation is granted once per box — serve executes arbitrary commands (kind=pwsh) from the
LAN, so an elevated listener turns a leaked fleet key into admin-level remote code execution.
It is installed as a Scheduled Task, never a Windows Service (FleetDispatch.exe is a
console app that never talks to the Service Control Manager; sc.exe create looks like it works
for 30 seconds, then Windows kills it with error 1053 — see README's Persistent Listener section
and FLT-71).
Two dispatch paths¶
flowchart LR
A[Operator CLI] --> B[Build canonical job JSON]
B --> C[HMAC-SHA256 sign with fleet key]
C --> D[enqueue: atomic write to OneDrive inbox]
D --> E[serve/listen queue drain]
E --> F{Schema version <= listener's?}
F -- no --> G1[archive/rejected: unsupported-schema]
F -- yes --> H{Verify HMAC}
H -- invalid --> G2[archive/rejected: invalid-hmac]
H -- valid --> I{Replay / wrong-target / cancelled?}
I -- yes --> G3[reject or honor cancel]
I -- no --> J{AvailabilityGate.Evaluate}
J -- refused --> K1[left in inbox: requeue next poll]
J -- allowed --> L{Governor RED/BLACKOUT and non-critical?}
L -- yes --> K2[deferred: left in inbox]
L -- no --> M[move inbox to processing]
M --> N[execute via IJobExecutor]
N --> O[atomic result to outbox]
O --> P[processing to archive/done]
P --> Q[Operator results]
C --> R[send: peer registry resolve]
R --> S[HTTP POST peer /dispatch]
S --> T{Verify X-Fleet-Auth bearer}
T -- invalid --> U1[401]
S --> V{Schema version ok, HMAC valid, target == this PC?}
V -- no --> U2[422 / 401 / 400]
V -- yes --> W{Replay / in-flight?}
W -- yes --> U3[409]
W -- no --> X{AvailabilityGate.Evaluate}
X -- refused --> U4[503 unavailable]
X -- allowed --> Y{Governor RED/BLACKOUT and non-critical?}
Y -- yes --> U5[503 deferred]
Y -- no --> Z[execute via IJobExecutor]
Z --> AA[atomic result to outbox + 200 response]
enqueue/listen/serve's queue-drain loop — the original, async, store-and-forward path.enqueueHMAC-signs a job and atomically writes it to%OneDrive%\PC-Bootstrap\_shared\dispatch\<PC>\inbox. The target box'slisten/servedrains its own inbox on a poll interval. Survives both machines being offline at different times — OneDrive is the durable transport.send/DispatchServer's/dispatch— the newer, synchronous, direct-peer path.sendresolves the target'shost:portfrom the peer registry and POSTs the signed job straight to its listener over HTTP; the caller gets the result (or a deferral/refusal status) in the same request. Requires both machines to be online and reachable now.
Both paths share the same DispatchJob wire shape, the same JobSigner HMAC verification, the
same AvailabilityGate and GovernorGate checks (in that order), and the same IJobExecutor —
the divergence is purely transport and durability, not job semantics.
Storage layout¶
Each target PC has, under the dispatch root (%OneDrive%\PC-Bootstrap\_shared\dispatch\<PC>,
overridable with FLEET_DISPATCH_ROOT):
inbox/ # signed jobs waiting to run (queue path only)
processing/ # a job currently executing (queue path only)
outbox/ # DispatchResult for a completed/rejected/cancelled job
archive/done/ # the original job file, moved here once outbox has a result
archive/rejected/ # job file + a <id>.reason.txt (invalid-hmac, unsupported-schema, ...)
archive/consumed/ # consumed cancel/steer markers
All writes are temp-file-then-File.Move(..., overwrite: true) (AtomicFile), so a reader never
observes a partially-written job or result even mid-OneDrive-sync. Paths are injectable through
FleetDispatchPaths for tests and nonstandard deployments.
Peer discovery (the send/DispatchServer path) reads %USERPROFILE%\.claude\fleet-peers.json
({ "PCNAME": "host:port" }, override with FLEET_DISPATCH_PEERS_FILE), then merges
%OneDrive%\PC-Bootstrap\_shared\peers\*.json. serve self-registers this PC's {name,host,port}
entry only after its listener has confirmed it is bound and accepting connections — never
before (FLT-71: the previous code advertised a listener that had not actually come up).
The signed job envelope¶
DispatchJob (src/FleetDispatch.Core/DispatchJob.cs):
| Field | Type | Notes |
|---|---|---|
Id |
string | GUID "D" format |
CreatedAt |
string | ISO-8601 ("O") |
Origin / Target |
string | sender / recipient PC name |
Lane |
string | required — a WT-xxxx worktree/lane label |
Kind |
string | a HarnessRegistry kind, lowercased |
Payload |
string | free text or a small JSON object, kind-dependent |
Cwd |
string? | sender's working dir; honored only if it exists on the target, else falls back to the user profile |
TimeoutSec |
int | clamped 1–7200 |
Critical |
bool | bypasses the governor gate (never the availability gate) |
JobClass |
string? | control\|trivial\|heavy_cpu\|heavy_gpu\|focus_risk; unset/unknown parses as heavy_cpu |
SchemaVersion |
int | wire-schema version this sender speaks; not part of the signed canonical |
Sig |
string | hex HMAC-SHA256, lowercase |
Canonical string and HMAC (JobSigner)¶
id|createdAt|origin|target|lane|kind|cwd|timeoutSec|critical|payload|jobClass
JobSigner.Sign computes HMACSHA256(key, UTF8(canonical)), hex-encoded, lowercase.
JobSigner.Verify recomputes the same string and compares with
CryptographicOperations.FixedTimeEquals (constant-time, avoids a timing side-channel on the
signature check).
Why JobClass is signed and SchemaVersion is not, both by design:
SchemaVersionis a compatibility hint, checked before the HMAC, so a listener can name a version-skew rejection (unsupported-schema:<n>) instead of lumping it in with a genericinvalid-hmac. It is deliberately excluded from the canonical: adding it would itself be a breaking signing change against every already-deployed listener the moment the version number changed — exactly the failure this field exists to make diagnosable (FLT-70). The accepted residual: an actor with queue-write but not the key can bump this unsigned field on an otherwise-valid job to force a permanent "update the listener" rejection — an integrity-preserving denial-of-service, no worse than the delete/move they can already do.JobClassdecides whether the availability hard gate lets a job run while the PC is gaming or streaming. An unsignedJobClasswould let anyone with queue-write (but not the fleet key) relabel aheavy_cpujob ascontrolto slip it past the gate — a live security property, not just a compatibility nicety. Appending it to the canonical is why this is wire-schema v2 (DispatchJob.CurrentSchemaVersion = 2): every job this codebase builds signs the v2 canonical by default, and a listener still running v1 code names the skew asunsupported-schemabefore it ever attempts (and fails) the HMAC check — the same mechanismSchemaVersionexists for, now exercised for real.
Verify flow (both paths, same order)¶
- Deserialize; malformed JSON →
invalid-json/invalid job json. job.SchemaVersion > DispatchJob.CurrentSchemaVersion→unsupported-schema:<n>, checked before the HMAC so version skew is nameable rather than reported as tampering.JobSigner.Verify→invalid-hmac/invalid signature.- Job id shape (
^[A-Za-z0-9-]{1,64}$) →invalid-job-id— defense in depth against path traversal even though the id is already inside a signed, verified payload. HarnessRegistry.IsKnown(job.Kind)→unknown-kind:<kind>— a signature-valid job for a kind this (older) listener has never heard of is dead-lettered, never silently run as a shell command (the pre-registry footgun this replaced).- Replay guard: a result or done-record already exists for this id →
replay-already-processed(queue path) / HTTP409(peer path, backed by an additional in-memoryInFlightset that closes the concurrent-replay race the on-disk check alone cannot). job.Targetmust equal this PC's own name →wrong-target:<pc>(queue path only; the peer path'ssendalready resolved a specific host, so a mismatch here means someone re-signed and re-sent a captured envelope, or misconfigured routing).- A pending cancel marker for this id, if any, is honored before the availability and governor gates — cancel is a control action and must not sit out either.
AvailabilityGate.Evaluate— see below. Refusal leaves the job untouched ininbox/(queue path) or returns503with nothing written tooutbox//done/(peer path) — a requeue, never a failure.GovernorGate.ShouldDefer—RED/BLACKOUTtier defers non-Criticaljobs the same way.- Execute via
IJobExecutor, write theDispatchResulttooutbox/, move the job file toarchive/done/.
Harness adapter model¶
HarnessRegistry (src/FleetDispatch.Core/HarnessRegistry.cs) is the single source of truth for
dispatchable job kinds — it replaced two independently-hardcoded claude|pwsh allowlists (queue
+ CLI) that could drift apart. Each HarnessKind declares a transport (process or http), an
optional argv byte cap, and a payload shape; ValidateEnqueue runs at enqueue time so a malformed
or oversized payload dies on the sender, not twenty minutes later on the listener.
| Kind | Transport | Payload | Notes |
|---|---|---|---|
claude |
process | prompt text or {model?, prompt} |
28,000-byte argv cap |
pwsh |
process | free-text PowerShell | escape hatch, arbitrary exec |
codex |
process | prompt text or {model?, prompt} |
ChatGPT CLI (rebranded Codex) |
agy |
process | prompt text or {model?, prompt} |
Antigravity/Gemini; 28,000-byte cap; agy-Claude model labels are refused (governor-flag-only lane) |
grok |
process | prompt text or {model?, prompt} |
web search off; 28,000-byte cap |
ollama |
process | {model?, prompt} |
default model qwen3:8b |
odysseus |
http | {method?, path, body?} |
executed listener-side against 127.0.0.1:7000 |
run_agent |
process | {agent, task, repo, ticket, branch?, mode?, timeoutSec?, validateCommand?} |
FLT-2 uniform contract: host-owned worktree + agent CLI + validation |
The 28,000-byte argv cap on claude/agy/grok exists because the prompt travels on
ProcessStartInfo.ArgumentList, and Windows rejects a CreateProcess call past a ~32,767-char
command line.
Execution (RealJobExecutor)¶
IJobExecutor is the seam — unit tests inject fakes; production wires RealJobExecutor.
claude: tries a liveClaudeStreamSession(stream-json protocol) first — its open stdin lets a mid-run steer marker become a queued conversation turn instead of a kill-and-restart. Any spawn/protocol failure (old CLI without stream-json support) falls through to the legacy argv path:claude -p <prompt> [--model <m>] --permission-mode bypassPermissions, with a fallback to--dangerously-skip-permissionsif the installed CLI rejects the primary flag.codex: tries a liveCodexAppServerSessionfirst — itsturn/steerreaches an in-flight turn, the strongest steering path in the fleet. Falls back to the staged-shellcodex execpath on any startup failure, under the timeout token that was already ticking (a slow-dying handshake does not gift the fallback a fresh fullTimeoutSec).agy/grok/ollama:RunStagedShellAsync— the payload/prompt is staged to a temp file, then invoked throughpwsh/powershell(not a direct exec) so PATH resolves both real binaries and npm.ps1shims, reading the prompt from the staged file closes stdin (agy and codex both hang on an open stdin otherwise).odysseus: no process spawn — a direct HTTP call from inside the listener process to the fixed loopback127.0.0.1:7000. Path validation rejects protocol-relative (//host/...), backslash, and absolute-URL shapes to close an SSRF path that would otherwise escape the fixed loopback host.pwsh:pwsh -NoProfile -ExecutionPolicy Bypass -Command <payload>(PowerShell 7 if installed, else Windows PowerShell — not every fleet box haspwsh, e.g. a TV-attached PC).run_agent: delegates toAgentRunHost, which owns worktree creation, timeout, retry, diff capture, and validation-command execution; the structuredAgentRunResultJSON rides back verbatim inStdoutTail.
Every kind fails closed on an unrecognized value — there is no shell fallthrough for an unknown
kind (the queue and /dispatch handler both dead-letter it before IJobExecutor is ever called).
Repo registry scanner¶
RepoRegistryScanner (src/FleetDispatch.Core/RepoRegistry/, FLT-126) is the per-PC scanner that
populates the fleet-wide repo inventory Mission Control's RepoRegistryReader reads. The wire
contract it targets (RepoInventoryFile / RepoInventoryEntry / RepoWorktreeEntry) is frozen in
fleet-mission-control/docs/repo-registry-contract.md — that repo owns the canonical contract
document; this repo does not carry its own copy, only a field-for-field, declaration-order-for-
declaration-order mirror of the contract types plus a byte-for-byte mirror of the signer
(RepoRegistrySigner.CanonicalString/Sign reproduce
RepoRegistryReader.CanonicalString/Sign exactly: same JsonSerializerOptions(JsonSerializerDefaults.Web),
same Pc|GeneratedAt|<serialized Repos> join, same HMAC-SHA256-hex-lowercase). Any drift in field
order, naming, or serializer options here breaks every signature this scanner writes against the
reader it targets.
- Approved roots only:
FleetDispatchPaths.DefaultCwd(%USERPROFILE%\source\repos) plusRepoScanOptions.ExtraRoots(FLEET_REPO_SCAN_EXTRA_ROOTS,;-separated). Depth is exactly one level under each root — the root's immediate subdirectories, plus the root itself if it directly is a repo. Never a recursive disk walk. - Main vs. worktree classification:
git rev-parse --git-dir --git-common-dirper candidate; equal paths mean a main checkout, different paths mean a linked worktree. A worktree is never emitted as its own top-level entry — it is folded into its main checkout'sWorktreeslist viagit worktree list --porcelainon the main repo, which reports every attached worktree by absolute path regardless of physical location (sibling directory, nested.claude/worktrees/<name>, or a different drive entirely). - Orphaned worktrees (main checkout not itself under an approved root) are logged and dropped, never promoted to a fabricated top-level entry.
RepoIdderivation: normalizedhost/owner/repofrom theoriginremote (internal, unit-testedRepoRegistryScanner.NormalizeRemote— handles bothgit@host:owner/repo.gitandhttps://[user@]host/owner/repo[.git]), falling back to the main checkout's folder name when there is noorigin.- Output:
fleet-dispatch repo-scansigns the scan with the same~/.claude/fleet-dispatch.keyevery listener already loads, writes{RepoInventoryDir}\{ComputerName}.jsonatomically, then reads it back and verifies it before reporting success.RepoInventoryDirresolves to{shared}\repo-inventoryunder the same%OneDrive%\PC-Bootstrap\_sharedparentDispatchRootalready uses (overrideFLEET_REPO_INVENTORY_DIR). - Trigger:
fleet-dispatch serverunsRunRepoScanLoopAsyncalongside its other loops — one scan at boot, then every--repo-scan-poll-hours(default 24);--no-repo-scandisables it. A failed scan logs and retries next tick; it never takes the listener down. There is deliberately no dedicatedrepo-scanHarnessKind— an on-demand scan rides the existingpwshkind with zero new wiring:fleet-dispatch enqueue --targets all --kind pwsh --lane WT-xxxx --payload "fleet-dispatch repo-scan". - Known gap: this loop calls
RepoRegistryScannerdirectly in-process — it never builds aDispatchJobor goes throughIJobExecutor, so it is not gated byAvailabilityGateorGovernorGatetoday (pre-existing gap, documented, not fixed by FLT-126).
Availability gate and detector¶
FLT-128/129 (src/FleetDispatch.Core/Availability/) exist so the fleet stops burdening a PC that
Andrew is actively gaming or streaming on. Full field-level contract:
docs/availability-contract.md. Two independent pieces:
AvailabilityGate(FLT-128, the hard gate) — pure, stateless, zero I/O. Called immediately before the listener executes any non-controljob, on a freshIAvailabilitySignalsProvider.GetSignals()read every single time. It never trusts the detector's cached state, and the governor cannot override a refusal from here — the two gates are entirely independent, checked in this order (gate first, governor second).AvailabilityDetector(FLT-129, the presence loop) — an always-on ~4s poll loop, a separate task from the gated execution loops, so the box keeps detecting its own re-availability even while every job is being refused. Applies hysteresis (see below) and publishes the result as theavailabilityobject onGET /health— advisory only, it never gates a job itself.
Gate rules, in order (AvailabilityGate.Evaluate)¶
controljobs always run.- An active (unexpired, non-
"auto") manual override wins over every live signal:"unavailable"refuses everything belowcontrol;"available"allows everything, even mid-game/mid-stream. - OBS running (
obs64.exe/obs32.exe) → refuse every non-controljob,trivialincluded (blockLevel: all). trivialjobs survive a gaming block (a gaming block isblockLevel: heavy, which only touchesheavy_cpu/heavy_gpu/focus_risk).- Steam
RunningAppID != 0, or an allowlisted non-Steam game process, → refuseheavy_cpu/heavy_gpu/focus_risk(blockLevel: heavy). - Otherwise, allow.
JobClass¶
control | trivial | heavy_cpu | heavy_gpu | focus_risk, set via --job-class on enqueue/send
(refused at enqueue if unrecognized — never silently downgraded). Unset/unrecognized parses as
heavy_cpu — fail closed. Part of the signed canonical (see above).
Detection signals (WindowsAvailabilitySignalsProvider)¶
- Steam:
HKCU\Software\Valve\Steam\RunningAppID(REG_DWORD;0= not gaming), read unconditionally — not gated behind any GPU check. Best-effort game-name resolution via{SteamPath}\steamapps\appmanifest_<appid>.acf's"name"field, falling back to"Steam AppID <n>"when unresolved. - Non-Steam games: a local, hand-edited JSON array allowlist of process names at
FleetDispatchPaths.GameAllowlistPath(default~/.claude/fleet-availability-allowlist.json, overrideFLEET_DISPATCH_GAME_ALLOWLIST); a missing file is an empty allowlist, not an error. - Streaming:
obs64.exe/obs32.exeprocess presence. - Manual override:
{DispatchRoot}/availability/{PC}.override.json, written by Mission Control, only ever read here —{ "pc", "override": "auto"|"unavailable"|"available", "expiresAt" }. Any override (either non-"auto"value) with a pastexpiresAtis treated as"auto"— expiry is symmetric so a stale"unavailable"can't linger any more than a stale"available"can. A missing or unparseable file fails open (treated as"auto"), not unavailable — a corrupt override file must never itself become a fleet-wide freeze.
All Windows-specific reads are guarded by OperatingSystem.IsWindows(); the manual-override-file
read is plain JSON I/O with no OS dependency and always runs.
Re-availability grace (detector only, never the hard gate)¶
- Gaming: 90 seconds of continuous quiet before
availableflips back totrue. - Streaming: 120 seconds.
- Any trip during the grace window resets that factor's timer to zero, not merely extends it.
- This exists purely so the published heartbeat never flickers
available: truefor the gap between "alt-tabbed for a second" and "actually still playing." The hard gate has no memory whatsoever — it always re-reads live signals fresh, so it is never fooled by a stale published state even for a few seconds.
See docs/diagrams/availability-state-machine.md for the state diagram.
Known gaps (documented, not built)¶
- No fullscreen+GPU heuristic for detecting an unlisted, non-Steam game (deferred — GPU-polling dependency and false-positive risk against video calls / GPU-accelerated browsers).
- The hard gate only intercepts before a job starts (
QueueService.ListenOnceAsync/DispatchServer's/dispatchhandler) — aheavy_cpujob already running when gaming/streaming begins is not paused or killed mid-run. - The repo-scan timer loop bypasses both this gate and the governor (see Repo registry scanner).
- No admin UI for the game allowlist file — hand-edited JSON today.
Request-path sequence: a signed job is picked up, gated, executed, reported¶
See docs/diagrams/job-signing-sequence.md for the full sequence diagram covering both the queue and peer paths end to end — sign, verify, gate, execute, report.
Cancel and steer lanes¶
Briefly, since they ride the same execution chokepoints described above:
- Cancel (
CancelService, FLT-101-adjacent): a signed marker file, checked before both the availability and governor gates so a cancel is never sat out by either. A cancel that races a successful completion loses gracefully — the honest success result is kept. - Steer (
SteerService, FLT-101/103): a signed marker that either (a) gets folded into a rebuilt prompt on next execution, or (b) forclaude/codexjobs running through a live stream/app-server session, is delivered as an actual mid-conversation turn. See docs/steer-lane.md and docs/steer-lane-consult.md for the full contract.
CI gates¶
Three workflows, .github/workflows/, all on self-hosted, windows runners in the home-ci
group (Windows PowerShell 5.1 shell wrapper — the runners' execution policy blocks GitHub's
default temp-script wrapping under a Restricted policy, and pwsh is a broken Store alias on
these boxes):
ci.yml(every push tomain/master, every PR): restore,dotnet build -warnaserror, a per-project vulnerable-package gate (dotnet list package --vulnerable --include-transitive, exit-code-first so a broken scan can't fail-open as clean),dotnet testwithXPlat Code Coverage, then a hard 80% line-coverage floor onFleetDispatch.Coreread out of the Cobertura report (the CLI/test projects are not held to this floor).inspect.yml(every push/PR): ReSharperInspectCodestatic analysis, gated on ERROR-severity findings only — warning/note volume on these repos is dominated by DTO-contract and public-API-surface noise, so gating on it would train the fleet to ignore a red X. Full SARIF uploaded every run for triage regardless.mutation.yml(weekly cron, Monday 08:00 UTC, plus on changes to the Stryker config): Stryker.NET mutation baseline. Report-only, not an enforcing gate — this infra scores VsTest socket collapses as killed mutants, which inflates a broad-scope score, and a full run takes minutes-to-hours on a small self-hosted pool. Tracked to drive the real score up over time, not yet promoted to a break threshold.