Skip to content

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):

  1. An HTTP accept loop (DispatchServer) bound to this PC's LAN IPv4 address (or Tailscale address when present) plus 127.0.0.1 — serves GET /health and POST /dispatch.
  2. A gated queue-drain loop (QueueService.ListenOnceAsync, polled every --poll seconds) that drains this PC's OneDrive inbox.
  3. An always-on availability presence loop (AvailabilityDetector, ~4s poll) — see Availability gate and detector.
  4. 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. enqueue HMAC-signs a job and atomically writes it to %OneDrive%\PC-Bootstrap\_shared\dispatch\<PC>\inbox. The target box's listen/serve drains 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. send resolves the target's host:port from 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:

  • SchemaVersion is 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 generic invalid-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.
  • JobClass decides whether the availability hard gate lets a job run while the PC is gaming or streaming. An unsigned JobClass would let anyone with queue-write (but not the fleet key) relabel a heavy_cpu job as control to 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 as unsupported-schema before it ever attempts (and fails) the HMAC check — the same mechanism SchemaVersion exists for, now exercised for real.

Verify flow (both paths, same order)

  1. Deserialize; malformed JSON → invalid-json / invalid job json.
  2. job.SchemaVersion > DispatchJob.CurrentSchemaVersionunsupported-schema:<n>, checked before the HMAC so version skew is nameable rather than reported as tampering.
  3. JobSigner.Verifyinvalid-hmac / invalid signature.
  4. 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.
  5. 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).
  6. Replay guard: a result or done-record already exists for this id → replay-already-processed (queue path) / HTTP 409 (peer path, backed by an additional in-memory InFlight set that closes the concurrent-replay race the on-disk check alone cannot).
  7. job.Target must equal this PC's own name → wrong-target:<pc> (queue path only; the peer path's send already resolved a specific host, so a mismatch here means someone re-signed and re-sent a captured envelope, or misconfigured routing).
  8. 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.
  9. AvailabilityGate.Evaluate — see below. Refusal leaves the job untouched in inbox/ (queue path) or returns 503 with nothing written to outbox//done/ (peer path) — a requeue, never a failure.
  10. GovernorGate.ShouldDeferRED/BLACKOUT tier defers non-Critical jobs the same way.
  11. Execute via IJobExecutor, write the DispatchResult to outbox/, move the job file to archive/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 live ClaudeStreamSession (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-permissions if the installed CLI rejects the primary flag.
  • codex: tries a live CodexAppServerSession first — its turn/steer reaches an in-flight turn, the strongest steering path in the fleet. Falls back to the staged-shell codex exec path on any startup failure, under the timeout token that was already ticking (a slow-dying handshake does not gift the fallback a fresh full TimeoutSec).
  • agy / grok / ollama: RunStagedShellAsync — the payload/prompt is staged to a temp file, then invoked through pwsh/powershell (not a direct exec) so PATH resolves both real binaries and npm .ps1 shims, 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 loopback 127.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 has pwsh, e.g. a TV-attached PC).
  • run_agent: delegates to AgentRunHost, which owns worktree creation, timeout, retry, diff capture, and validation-command execution; the structured AgentRunResult JSON rides back verbatim in StdoutTail.

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) plus RepoScanOptions.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-dir per 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's Worktrees list via git worktree list --porcelain on 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.
  • RepoId derivation: normalized host/owner/repo from the origin remote (internal, unit-tested RepoRegistryScanner.NormalizeRemote — handles both git@host:owner/repo.git and https://[user@]host/owner/repo[.git]), falling back to the main checkout's folder name when there is no origin.
  • Output: fleet-dispatch repo-scan signs the scan with the same ~/.claude/fleet-dispatch.key every listener already loads, writes {RepoInventoryDir}\{ComputerName}.json atomically, then reads it back and verifies it before reporting success. RepoInventoryDir resolves to {shared}\repo-inventory under the same %OneDrive%\PC-Bootstrap\_shared parent DispatchRoot already uses (override FLEET_REPO_INVENTORY_DIR).
  • Trigger: fleet-dispatch serve runs RunRepoScanLoopAsync alongside its other loops — one scan at boot, then every --repo-scan-poll-hours (default 24); --no-repo-scan disables it. A failed scan logs and retries next tick; it never takes the listener down. There is deliberately no dedicated repo-scan HarnessKind — an on-demand scan rides the existing pwsh kind with zero new wiring: fleet-dispatch enqueue --targets all --kind pwsh --lane WT-xxxx --payload "fleet-dispatch repo-scan".
  • Known gap: this loop calls RepoRegistryScanner directly in-process — it never builds a DispatchJob or goes through IJobExecutor, so it is not gated by AvailabilityGate or GovernorGate today (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-control job, on a fresh IAvailabilitySignalsProvider.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 the availability object on GET /health — advisory only, it never gates a job itself.

Gate rules, in order (AvailabilityGate.Evaluate)

  1. control jobs always run.
  2. An active (unexpired, non-"auto") manual override wins over every live signal: "unavailable" refuses everything below control; "available" allows everything, even mid-game/mid-stream.
  3. OBS running (obs64.exe/obs32.exe) → refuse every non-control job, trivial included (blockLevel: all).
  4. trivial jobs survive a gaming block (a gaming block is blockLevel: heavy, which only touches heavy_cpu/heavy_gpu/focus_risk).
  5. Steam RunningAppID != 0, or an allowlisted non-Steam game process, → refuse heavy_cpu/heavy_gpu/focus_risk (blockLevel: heavy).
  6. 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, override FLEET_DISPATCH_GAME_ALLOWLIST); a missing file is an empty allowlist, not an error.
  • Streaming: obs64.exe / obs32.exe process 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 past expiresAt is 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 available flips back to true.
  • 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: true for 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 /dispatch handler) — a heavy_cpu job 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) for claude/codex jobs 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 to main/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 test with XPlat Code Coverage, then a hard 80% line-coverage floor on FleetDispatch.Core read out of the Cobertura report (the CLI/test projects are not held to this floor).
  • inspect.yml (every push/PR): ReSharper InspectCode static 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.