Skip to content

BUILD-REPORT-FLT131: unavailable-PC mode (Mission Control side)

Worktree wt/WT-6022/FLT-131, off origin/master @ 19d1f91 (has FLT-120/122/125). Not merged/pushed.

What was built

FLT-130 — placement-skip

  • Contracts/AvailabilityModels.cs (new): HostAvailability (the heartbeat's availability object: available/mode/reason/detail/blockLevel/since), AvailabilityOverride (the file MC writes), SetAvailabilityRequest (the toggle's POST body). Field names/casing match the given contract exactly.
  • FleetPaths.AvailabilityDir = {DispatchRoot}/availability (+ AvailabilityDirName const).
  • FleetDispatchReader.GetAvailability(pc) reads {AvailabilityDir}/{pc}.json; wired onto WorkerStatus.Availability in GetSnapshotAsync. Null-safe: absent file → null → "no restriction" everywhere downstream.
  • Bug caught by a test, fixed: FleetDispatchReader.ListTargets() enumerates every subdirectory of DispatchRoot as a "PC". Adding availability/ under DispatchRoot (the contract's own path) made it show up as a phantom worker. Fixed by excluding FleetPaths.AvailabilityDirName from that enumeration — covered by Snapshot_CarriesAvailabilityOntoTheMatchingWorker.
  • AvailabilityGate (new, pure/static): IsEligible(availability, isHeavyJob) — fails open on null, passes on Available=true, otherwise gated by BlockLevel (all blocks everything, heavy blocks only heavy jobs, anything else/unknown never blocks). Describe() for the refusal message.
  • Wired into AgentRunDispatcher.Enqueue (the ONE choke point both SendAsync (run_agent) and SendDesign go through) — read fresh at enqueue time, throws AgentRunRejectedException (→ 400) when blocked.
  • Job-class ("heavy") classification: run_agent has no tier/routing concept of its own, so it reuses TierLadder.Classify(task,...).Tier == Ceiling — the same signal the design lane already trusts. Design jobs use the tier CapabilityRouter.Plan already computed (SendDesign(..., tier)).

FLT-131 — host status + manual toggle

  • WorkerStatus.Availability (nullable) flows through the existing GET /api/workers snapshot and the WorkerHeartbeat hub push — no new read endpoint needed.
  • AvailabilityWriter (new): validates + atomically writes {DispatchRoot}/availability/{pc}.override.json per the given contract. Rules: auto clears (no expiry); unavailable is sticky unless durationMinutes given; available always carries a TTL (DefaultForceAvailableMinutes=45, capped at 24h). Unsigned (like PeerEntry/ repo-inventory, unlike DispatchJob) — reasoning is in the file's doc comment: it can only ever make one host look wrongly available/unavailable, never trigger code execution.
  • POST /api/hosts/{pc}/availability (new endpoint, RequireFleetAuth) — the toggle's write path.
  • Frontend: worker-list.component.ts rewritten — each row now shows an availability line (unavailable · gaming Elden Ring / — streaming (OBS) / available in 04:23 while inside a per-reason grace window) plus the three-state toggle (Auto | Force unavailable [+ duration select: sticky/1h/3h/until-tomorrow] | Force available). CommandService.setAvailability() added, same withSession pattern as launch/cancel.
  • command-panel.component.ts target dropdown: unavailable hosts get a — unavailable · <reason> suffix and a .muted class — never removed from the list (backend gate is the real enforcement; this is advisory so an operator isn't surprised by a 400 after filling the form).

Mid-task addition — job-signing protocol v2 (coordinated with fleet-dispatch)

fleet-dispatch bumped its wire protocol: DispatchJob.SchemaVersion 1→2, and JobSigner appends JobClass as the new trailing canonical field. A v2 listener rejects v1-signed jobs. Updated MC's own signer mirror to match exactly: - DispatchJob gained SchemaVersion (default/CurrentSchemaVersion=2) and JobClass. - AgentRunDispatcher.CanonicalString appends job.JobClass ?? "" as the last |-joined field (JobClass is server-derived, never attacker-influenced, so the "nothing after Payload can shift a boundary" property still holds even though JobClass is now literally last). - Every job MC builds is tagged: run_agent → heavy_cpu, design/artifact → heavy_gpu. focus_risk/control are defined for contract parity but nothing here emits them (MC doesn't dispatch window/IDE or heartbeat jobs). - Enqueue stamps SchemaVersion = DispatchJob.CurrentSchemaVersion explicitly on every job.

Contract note / TODO for whoever lands the fleet-dispatch half

fleet-dispatch had not shipped its availability heartbeat at the time this was written. I picked {DispatchRoot}/availability/{pc}.json as the read path (paired with the override file's given {pc}.override.json in the same directory) — reconcile FleetDispatchReader.GetAvailability / FleetPaths.AvailabilityDir if the real listener publishes somewhere else or under a different shape. The HostAvailability C# record matches the given wire contract field-for-field.

The frontend's "available in MM:SS" countdown is a client-side heuristic, not a promise from the wire: HostAvailability has no expiry field for auto-detected (gaming/streaming) states — how could it, a game's end time is unknowable. It's since + a per-reason grace constant (gaming 5min, streaming 10min, in worker-list.component.ts), shown only while that window hasn't elapsed. Tune/replace if the real listener publishes a debounce window explicitly.

Build + test results

  • dotnet build -warnaserror: 0 warnings, 0 errors.
  • dotnet test: 429/429 passed (424 pre-existing/adjacent + ~35 new/modified across AvailabilityGateTests, AvailabilityWriterTests, FleetDispatchReaderTests additions, AgentRunDispatcherTests placement-skip + v2-signer additions, FleetApiAuthTests v2-signer additions). 0 failed, 0 skipped.
  • ng build: clean.
  • ng lint: all files pass.
  • npx vitest run (the standalone Stryker-oriented config, run directly per instructions — NOT just ng test): 206/206 passed across 19 files, including 20 in worker-list.component.spec.ts (8 pre-existing + 12 new: pure availabilityLabel cases, row rendering, toggle write-path incl. duration threading) and the new command-panel dropdown-greying test.
  • npm run generate:client: run after every backend contract change; fleet-api.generated.ts and openapi/FleetMissionControl.Api.json are committed and current.

Scope discipline

Did not touch cards/artifact-run/repo-registry internals beyond the one-line SendDesign call site that now passes decision.Tier through (required for the placement gate to know "heavy" for design jobs — CapabilityRouter itself is untouched).

Known follow-ups (not blocking, out of this ticket's scope)

  • No integration/endpoint-level test hits POST /api/hosts/{pc}/availability directly (this repo has no WebApplicationFactory-style endpoint tests at all today — AvailabilityWriter is tested at the service level, matching every other write path here).
  • The force-unavailable duration selector's "until tomorrow" is computed client-side at click time (minutes to local midnight) — fine for a single-cockpit-host operator UI, would need a server-side timezone if this ever serves remote viewers in another timezone.