Skip to content

Unavailable-mode availability contract — FLT-128 / FLT-129

Status: implemented, fleet-dispatch side (this repo — the listener). Mission Control's builder implements the other half (reading availability off the WorkerHeartbeat, writing the manual override file) against this exact contract — do not drift the shapes below without updating both sides.

Why

Andrew games (mostly Steam) and streams (OBS) on fleet PCs. The fleet must stop burdening a PC that's being used for that. Three pieces exist:

  • FLT-128 — the hard gate. Stateless, execution-time, absolute authority. Re-checks live signals immediately before the listener runs any non-control job and refuses (requeues, never fails) when unavailable. The governor (GovernorGate) cannot override a refusal here — they are two entirely independent checks.
  • FLT-129 — the detector. An always-on ~4s poll loop, separate from the gated job-execution path, that applies re-availability grace (hysteresis) and publishes the result as the WorkerHeartbeat's availability object. Advisory only — it never gates a job itself.
  • jobClass — an additive field on the job envelope that says how heavy/disruptive a job is, so the gate knows what to block.

jobClass

control | trivial | heavy_cpu | heavy_gpu | focus_risk. Unset or unrecognized parses as heavy_cpu (fail closed — blocked while unavailable, never silently let through).

jobClass Blocked by gaming (blockLevel heavy) Blocked by streaming (blockLevel all)
control never never
trivial never always
heavy_cpu always always
heavy_gpu always always
focus_risk always always

Producer tagging (this repo's own dispatched jobs):

  • heartbeat/status → control
  • governor scan / board sync / repo-scan / git fetch → trivial (e.g. fleet-dispatch enqueue --kind pwsh --job-class trivial --payload "fleet-dispatch repo-scan")
  • builds/tests → heavy_cpu
  • local-Ollama council seats / image / video / GPU inference → heavy_gpu
  • anything opening a window/shell/IDE/browser → focus_risk

--job-class is a CLI flag on enqueue and send (see fleet-dispatch kinds/help). Omitting it enqueues heavy_cpu.

Signing

JobClass is part of the signed canonical (JobSigner.CanonicalString, appended at the end), unlike SchemaVersion. An unsigned jobClass would let anyone with queue-write (but not the fleet key) relabel a heavy job as control to slip it past the gate while the box is gaming/streaming — that's a live security property, not just a compatibility hint. This is canonical v2 (DispatchJob.CurrentSchemaVersion = 2); a listener still on v1 rejects a v2 job with unsupported-schema before ever attempting (and failing) the HMAC check, exactly like every prior breaking canonical change in this codebase.

WorkerHeartbeat availability object

Surfaced today as an added field on this repo's /health response (DispatchServer):

{
  "computerName": "...",
  "tier": "GREEN",
  "version": "...",
  "availability": {
    "available": true,
    "mode": "auto",
    "reason": null,
    "detail": null,
    "blockLevel": "none",
    "since": "2026-07-17T12:00:00.0000000+00:00"
  }
}

Field semantics:

  • available (bool)
  • mode: "auto" (automatic detection) | "manual" (an active override file is in effect)
  • reason: "gaming" | "streaming" | "manual" | null
  • detail: free text — resolved game name / "Steam AppID <n>" / allowlisted process name / null
  • blockLevel: "none" | "heavy" (gaming: blocks heavy_cpu/heavy_gpu/focus_risk, allows control+trivial) | "all" (streaming or manual-unavailable: allows control only)
  • since: ISO-8601 — when this (available, reason) pair started, not merely the last poll time

This is populated from the background AvailabilityDetector loop (hysteresis applied, ~4s poll), never a fresh live read — a fresh read is what the hard gate does, on every job, independently.

Manual override file (Mission Control writes, fleet-dispatch only reads)

Path: {DispatchRoot}/availability/{PC}.override.json, where {DispatchRoot} is resolved exactly the way the listener already resolves it (FleetDispatchPaths.DispatchRoot / FLEET_DISPATCH_ROOT, shared-OneDrive default).

{ "pc": "ANDYGREATROOMPC", "override": "auto", "expiresAt": null }
  • override: "auto" (no override — defer to automatic detection) | "unavailable" (force unavailable) | "available" (force available, overriding a live gaming/streaming signal — the human is asserting the automatic signal is wrong or doesn't matter right now)
  • expiresAt: ISO-8601 or null. Any override value with a past expiresAt is treated as "auto" — not just "available"; expiry is symmetric so a stale "unavailable" override can't linger forever either.
  • A missing or unparseable file is treated as "auto" (fail OPEN on a corrupt file — a malformed override must never itself become a fleet-wide freeze).

Precedence (both the hard gate and the detector apply this identically)

  1. control jobClass always runs — nothing below this line applies to it.
  2. An active (not expired, not "auto") manual override wins over every live signal:
  3. "unavailable" → refuse everything but control, blockLevel: "all".
  4. "available" → allow everything, even mid-game/mid-stream.
  5. OBS (obs64.exe/obs32.exe) running → refuse everything but control (blockLevel: "all").
  6. trivial jobClass survives a gaming block (only OBS/manual can block it).
  7. Steam RunningAppID != 0, or an allowlisted non-Steam game process running → refuse heavy_cpu/heavy_gpu/focus_risk (blockLevel: "heavy").
  8. Otherwise: allow.

Detection signals (this repo's 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 (falls back to "Steam AppID <n>" when unresolved).
  • Non-Steam games: a local, per-PC JSON array allowlist of process names, e.g. ["eldenring.exe"], at FleetDispatchPaths.GameAllowlistPath (default ~/.claude/fleet-availability-allowlist.json, override via FLEET_DISPATCH_GAME_ALLOWLIST). Missing file = empty allowlist, not an error.
  • Streaming: obs64.exe / obs32.exe process presence.
  • Manual override: the file above.

All Windows-specific reads (registry, Process.GetProcessesByName) are guarded by OperatingSystem.IsWindows(); the manual-override-file read is plain JSON I/O with no OS dependency and always runs. The pure decision logic (AvailabilityGate, AvailabilityDetectorLogic) takes an AvailabilitySignals record as input and has zero I/O of its own, so it's fully unit-testable against a fake IAvailabilitySignalsProvider — no real game, OBS, or registry required.

Re-availability grace (detector only, never the hard gate)

  • Gaming: 90s of continuous quiet before the published available flips back to true.
  • Streaming: 120s.
  • Any trip during the grace window resets that factor's timer to zero — it is not merely extended.
  • This hysteresis exists purely so Mission Control never sees available: true flicker on in the gap between "alt-tabbed for a second" and "actually still playing", and start dispatching heavy jobs into that gap. The FLT-128 hard gate has no memory whatsoever — it always re-reads live signals fresh, so even if the detector's published state is stale/wrong for a few seconds, the gate itself is never fooled by it.

Known gaps / TODOs (not built here)

  • P2 fullscreen+GPU heuristic for detecting an unlisted game (no Steam, not on the allowlist). Left as a TODO per the FLT-129 spec — building it now would add a GPU-usage polling dependency and false-positive risk (video calls, GPU-accelerated browsers) that wasn't in scope for this pass.
  • FLT-112 pause-in-flight: today, a refusal only stops a job from starting. A heavy_cpu job already running when gaming/streaming starts is NOT paused or killed mid-run by this change — the hard gate only intercepts at the pre-execution chokepoint (QueueService.ListenOnceAsync / DispatchServer's /dispatch handler). Pausing or cooperatively yielding an in-flight job is a separate, harder problem (the job's own process would need to expose a pause hook) and is explicitly out of scope here.
  • Direct, non-queued execution paths are not gated. fleet-dispatch serve's own RunRepoScanLoopAsync timer calls RepoRegistryScanner directly in-process — it never builds a DispatchJob or goes through IJobExecutor, so neither this hard gate nor GovernorGate apply to it today. (This mirrors an existing gap: that loop was already ungated by the governor before this change.) If this loop needs gating too, it would need its own explicit AvailabilityGate.Evaluate(JobClass.Trivial, ...) check inserted into Program.RunRepoScanLoopAsync.
  • No admin UI for the game allowlist file — it's hand-edited JSON today (fleet-availability-allowlist.json).