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-
controljob 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
availabilityobject. 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"|nulldetail: free text — resolved game name /"Steam AppID <n>"/ allowlisted process name /nullblockLevel:"none"|"heavy"(gaming: blocksheavy_cpu/heavy_gpu/focus_risk, allowscontrol+trivial) |"all"(streaming or manual-unavailable: allowscontrolonly)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 ornull. Any override value with a pastexpiresAtis 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)¶
controljobClass always runs — nothing below this line applies to it.- An active (not expired, not
"auto") manual override wins over every live signal: "unavailable"→ refuse everything butcontrol,blockLevel: "all"."available"→ allow everything, even mid-game/mid-stream.- OBS (
obs64.exe/obs32.exe) running → refuse everything butcontrol(blockLevel: "all"). trivialjobClass survives a gaming block (only OBS/manual can block it).- Steam
RunningAppID != 0, or an allowlisted non-Steam game process running → refuseheavy_cpu/heavy_gpu/focus_risk(blockLevel: "heavy"). - 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"], atFleetDispatchPaths.GameAllowlistPath(default~/.claude/fleet-availability-allowlist.json, override viaFLEET_DISPATCH_GAME_ALLOWLIST). Missing file = empty allowlist, not an error. - Streaming:
obs64.exe/obs32.exeprocess 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
availableflips back totrue. - 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: trueflicker 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_cpujob 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/dispatchhandler). 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 ownRunRepoScanLoopAsynctimer callsRepoRegistryScannerdirectly in-process — it never builds aDispatchJobor goes throughIJobExecutor, so neither this hard gate norGovernorGateapply 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 explicitAvailabilityGate.Evaluate(JobClass.Trivial, ...)check inserted intoProgram.RunRepoScanLoopAsync. - No admin UI for the game allowlist file — it's hand-edited JSON today
(
fleet-availability-allowlist.json).