Skip to content

BUILD-REPORT-FLT128: unavailable-mode hard gate + detector + jobClass

Branch: wt/WT-6022/FLT-128 (worktree fd-FLT128, off origin/master @ 86a620b). Not merged to master.

What was built

FLT-128 — the stateless execution-time hard gate

  • AvailabilityGate.Evaluate(jobClass, signals, now) — pure static function, zero I/O (src/FleetDispatch.Core/Availability/AvailabilityGate.cs). Rules: control always runs; an active manual override wins over live signals in either direction; OBS running refuses everything but control; trivial survives a gaming block; Steam RunningAppID != 0 or an allowlisted process refuses heavy_cpu/heavy_gpu/focus_risk.
  • Wired as the first check (before GovernorGate.ShouldDefer) in both execution chokepoints:
  • QueueService.ListenOnceAsync — refusal leaves the job file untouched in inbox/ (never moved to processing/), exactly like a governor defer. New ListenSummary.Unavailable counter, logged distinctly from Deferred.
  • DispatchServer's /dispatch handler — refusal returns HTTP 503 with {deferred:true, unavailable:true, reason, detail, blockLevel}; nothing is written to outbox/done, so the sender's own retry is the requeue (same contract the governor's 503 already uses).
  • Re-reads live signals fresh on every job via IAvailabilitySignalsProvider.GetSignals() — never trusts the detector's cached/hysteresis'd state.

FLT-129 — the detector (presence loop) + heartbeat publish

  • AvailabilityDetectorLogic.Evaluate — pure hysteresis logic (gaming: 90s clear-quiet grace; streaming: 120s; any trip resets the relevant timer). AvailabilityDetector wraps it in an always-on ~4s poll loop, run as its own task in serve (separate from the gated queue-drain task, per spec — the listener keeps detecting re-availability even while every job is refused).
  • Signals: WindowsAvailabilitySignalsProviderHKCU\Software\Valve\Steam\RunningAppID (never gated behind GPU), best-effort game-name resolution via appmanifest_<id>.acf, obs64/obs32 process presence, a local JSON allowlist for non-Steam games (FleetDispatchPaths.GameAllowlistPath), and the manual override file (ManualOverrideFile, plain JSON I/O, not OS-gated).
  • Published as a new availability object on /health (this repo's WorkerHeartbeat): {available, mode, reason, detail, blockLevel, since} — see docs/availability-contract.md for the exact shared shape.

jobClass

  • DispatchJob.JobClass (nullable string) added to the envelope. JobClassification.Parse defaults unknown/unset to heavy_cpu (fail closed).
  • Signed: appended to JobSigner.CanonicalString (an unsigned field would let a queue-write-only actor relabel a job to control to dodge the gate). This is canonical v2DispatchJob.CurrentSchemaVersion bumped 1 → 2, so an out-of-date listener names the skew (unsupported-schema) instead of a blind HMAC failure, exactly per the existing FLT-70 mechanism.
  • --job-class CLI flag on enqueue/send (validated, case-insensitive, refuses an unknown value at enqueue). SteerService.FoldPending carries JobClass through a steer-triggered rebuild.

Build + test

dotnet build -warnaserror   → Build succeeded. 0 Warning(s). 0 Error(s).
dotnet test                 → Passed! Failed: 0, Passed: 436, Skipped: 1, Total: 437

The 1 skip (AgentRunLiveAgyTests...) is pre-existing and unrelated (a live-agy e2e test that skips without the real agy CLI on this box). 60 of the 436 passing tests are new (tests/FleetDispatch.Tests/Availability/*), covering: gate refusal for gaming/streaming/manual per jobClass, control-always-runs, trivial-survives-gaming-not-streaming, unknown-jobClass defaults to heavy_cpu, manual-override expiry (both values, not just "available"), grace-timer hold/reset math (continuous-poll simulation), QueueService/DispatchServer integration (requeue-in-inbox / 503 + no-outbox-write), /health surfacing the detector's published state, and JobSigner.CanonicalString/tamper coverage for the new signed field.

Files

  • src/FleetDispatch.Core/Availability/ (new): JobClass.cs, AvailabilitySignals.cs, IAvailabilitySignalsProvider.cs, ManualOverrideFile.cs, AvailabilityGate.cs, WorkerAvailability.cs, AvailabilityDetectorLogic.cs, AvailabilityDetector.cs, WindowsAvailabilitySignalsProvider.cs
  • Modified: DispatchJob.cs, JobSigner.cs, FleetDispatchPaths.cs, QueueService.cs, DispatchServer.cs, SteerService.cs, FleetDispatch.Core.csproj, Program.cs
  • docs/availability-contract.md (new) — the shared contract Mission Control's builder implements against.
  • tests/FleetDispatch.Tests/Availability/ (new, 8 files) + SigningTests.cs (updated for the new canonical shape).

Shared contract (see docs/availability-contract.md for full detail)

  • availability object on WorkerHeartbeat (/health): {available, mode, reason, detail, blockLevel, since}.
  • Manual override file: {DispatchRoot}/availability/{PC}.override.json = {pc, override: "auto"|"unavailable"|"available", expiresAt}. Any override with a past expiresAt → treated as "auto" (both values, not just "available" — a design decision documented in the contract doc).

TODOs / known gaps (documented in docs/availability-contract.md)

  • P2 fullscreen+GPU heuristic for an unlisted, non-Steam game — explicitly deferred per the FLT-129 spec (GPU-polling dependency + false-positive risk not worth taking on this pass).
  • FLT-112 pause-in-flight: the hard gate only intercepts before a job starts. A heavy_cpu job already running when gaming/streaming begins is not paused or killed by this change — pausing an in-flight job needs its own cooperative-yield design and is out of scope here.
  • Non-queued direct-execution paths are ungated: serve's own repo-scan timer loop (RunRepoScanLoopAsync) calls the scanner directly, bypassing IJobExecutor entirely — neither this gate nor GovernorGate apply to it (pre-existing gap, not introduced here). Flagged in the contract doc with the specific fix if it's ever wanted.
  • No admin UI for the local game-process allowlist file (hand-edited JSON today).

Commit

wt/WT-6022/FLT-128, not merged/pushed to master. Explicit git add of touched paths, no git add -A.