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'savailabilityobject: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(+AvailabilityDirNameconst).FleetDispatchReader.GetAvailability(pc)reads{AvailabilityDir}/{pc}.json; wired ontoWorkerStatus.AvailabilityinGetSnapshotAsync. Null-safe: absent file → null → "no restriction" everywhere downstream.- Bug caught by a test, fixed:
FleetDispatchReader.ListTargets()enumerates every subdirectory ofDispatchRootas a "PC". Addingavailability/underDispatchRoot(the contract's own path) made it show up as a phantom worker. Fixed by excludingFleetPaths.AvailabilityDirNamefrom that enumeration — covered bySnapshot_CarriesAvailabilityOntoTheMatchingWorker. AvailabilityGate(new, pure/static):IsEligible(availability, isHeavyJob)— fails open on null, passes onAvailable=true, otherwise gated byBlockLevel(allblocks everything,heavyblocks only heavy jobs, anything else/unknown never blocks).Describe()for the refusal message.- Wired into
AgentRunDispatcher.Enqueue(the ONE choke point bothSendAsync(run_agent) andSendDesigngo through) — read fresh at enqueue time, throwsAgentRunRejectedException(→ 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 tierCapabilityRouter.Planalready computed (SendDesign(..., tier)).
FLT-131 — host status + manual toggle¶
WorkerStatus.Availability(nullable) flows through the existingGET /api/workerssnapshot and theWorkerHeartbeathub push — no new read endpoint needed.AvailabilityWriter(new): validates + atomically writes{DispatchRoot}/availability/{pc}.override.jsonper the given contract. Rules:autoclears (no expiry);unavailableis sticky unlessdurationMinutesgiven;availablealways carries a TTL (DefaultForceAvailableMinutes=45, capped at 24h). Unsigned (likePeerEntry/repo-inventory, unlikeDispatchJob) — 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.tsrewritten — each row now shows an availability line (unavailable · gaming Elden Ring/— streaming (OBS)/available in 04:23while 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, samewithSessionpattern as launch/cancel. command-panel.component.tstarget dropdown: unavailable hosts get a— unavailable · <reason>suffix and a.mutedclass — 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 acrossAvailabilityGateTests,AvailabilityWriterTests,FleetDispatchReaderTestsadditions,AgentRunDispatcherTestsplacement-skip + v2-signer additions,FleetApiAuthTestsv2-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 justng test): 206/206 passed across 19 files, including 20 inworker-list.component.spec.ts(8 pre-existing + 12 new: pureavailabilityLabelcases, 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.tsandopenapi/FleetMissionControl.Api.jsonare 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}/availabilitydirectly (this repo has no WebApplicationFactory-style endpoint tests at all today —AvailabilityWriteris 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.