type: meta

slug: offload-robustness

topic: Build the laptop→cluster build-offload CONTROLLER as a new overdeck Bun service controller/ (systemd overdeck-controller, 127.0.0.1:8787), implementing the wire contract in docs/specs/2026-07-18-offload-control-plane-spec.md that overdeck A8 already consumes. Phase-1 greenfield ONLY — does NOT touch the live ~/.claude engine (that seam is manual Phase-2). This run = FOUNDATION PILOT: C1 scaffold+server+gate-wiring, C2 store+state-machine+transition-API+/status+fail-closed-config. Follow-on runs add R6 (events/metrics//api/v1/query), R3/R4/R5, R7. Base = overdeck main.

exec_mode: ship

scheduler: dag-parallel

land_mode: merge-to-main

base_branch: main

gate0_mode: strict

preset: codex-cursor


type: task

seat: coder

tier: medium

desc:
Scaffold a NEW Bun service overdeck/controller/ that mirrors the existing collector/ service exactly. READ FIRST, then copy their shape/conventions (do NOT invent new mechanisms): collector/package.json (scripts, deps, packageManager), collector/tsconfig.json, collector/src/server.ts (Bun.serve routing + bearer auth), collector/src/token.ts (bearer token read from a FILE mode 0600, fail-fast when configured-but-empty), collector/src/config.ts (config load). Also read the packaging/ dir for the existing overdeck-collector systemd unit to model the new one.

STEP 1 — workspace + tooling. Create controller/package.json with the SAME test+typecheck script conventions collector uses (a `typecheck` running tsc --noEmit and a `test` running bun test) and deps: zod (already used across the repo) + whatever collector uses for its server; controller/tsconfig.json mirroring collector's. Add "controller" to pnpm-workspace.yaml packages list so root `pnpm -r typecheck` and `pnpm -r test` include it (collector is already listed there — add controller the same way). CRITICAL — adding a package to pnpm-workspace.yaml makes the root workspace lockfile stale: immediately after editing pnpm-workspace.yaml, run `pnpm install` at the REPO ROOT (NOT inside controller/) to regenerate pnpm-lock.yaml so it gains a `controller` importer entry (collector already has one under `importers:` — controller MUST have one too), and COMMIT the updated pnpm-lock.yaml. A missing controller importer makes every downstream task's frozen-lockfile worktree provisioning fail with ERR_PNPM_OUTDATED_LOCKFILE. Separately, run `cd controller && bun install` and COMMIT controller/bun.lock (do it right — do not leave either lockfile untracked).

STEP 2 — server skeleton binding 127.0.0.1:8787 (distinct from collector's 31338; make the default a named constant). Implement bearer auth in controller/src/token.ts mirroring collector's token.ts (token from ~/.config/overdeck/controller-token mode 0600; per the spec Auth section, a configured-but-EMPTY token path is a fail-fast startup error; a missing Authorization on a protected route → 401). controller/src/server.ts: `GET /health` → 200 {ok:true} (unauthenticated); `GET /status` → requires auth, returns a minimal but SCHEMA-VALID ControllerStatus. Read the EXACT ControllerStatus shape from docs/specs/2026-07-18-offload-control-plane-spec.md (fields desired, observed, revision, lease, capacity, reconciler, dispatch, hosts, queue, jobs — the spec is normative, it WINS over this prose). controller/src/status.ts exports the zod schema for ControllerStatus + a builder returning the empty-but-valid status (revision 0, hosts {}, jobs [], reconciler.healthy true, lease.active false). Do NOT implement the SQLite store or any transition here — that is task c2.

STEP 3 — packaging/overdeck-controller.service modeled on the overdeck-collector unit: systemd --user, ExecStart at the REAL /usr/bin/bun (NEVER a ~/.claude/bin shim), WorkingDirectory the controller dir, a WatchdogSec + Restart, After/Wants matching the collector unit's pattern.

PHASE-1 BOUNDARY: do NOT read, edit, or depend on any ~/.claude file; the controller is standalone greenfield. Acceptance: `cd controller && bun test src/server.test.ts` PASS — server boots; GET /health → 200; GET /status with no token → 401; GET /status with the token → 200 and the body zod-parses against the ControllerStatus schema (revision 0, empty hosts). AND `cd controller && pnpm typecheck` clean. Commit only: controller/package.json, controller/tsconfig.json, controller/src/server.ts, controller/src/config.ts, controller/src/token.ts, controller/src/status.ts, controller/src/server.test.ts, controller/bun.lock, pnpm-workspace.yaml, pnpm-lock.yaml, packaging/overdeck-controller.service.


desc:
Implement the controller spine on top of task c1's skeleton, per docs/specs/2026-07-18-offload-control-plane-spec.md — READ the spec's ControllerStatus, Transition API, and Auth sections BY PATH FIRST; the spec is normative and WINS over this prose. Stay within overdeck/controller/; PHASE-1 BOUNDARY: touch NO ~/.claude file.

(1) controller/src/store.ts — a SQLite WAL store using bun:sqlite. Holds: desired + observed state; a monotonic INTEGER `revision` incremented on every ACCEPTED transition; the local-fallback lease {active, expiresAt, host, reason} which is ALWAYS expiring (no permanent fallback); per-host capacity; and a 24h idempotency-replay store keyed idempotencyKey → {revision, result}. Crash-resumable: constructing the store re-opens the DB and re-derives current state; EVERY mutation runs inside a transaction so a kill mid-write leaves a consistent revision (never a half-applied transition).

(2) controller/src/transitions.ts — the transition state machine for the 8 verbs (box-drain, box-restore, host-quarantine, host-unquarantine, admission-reconcile, job-retry, ci-reconcile, recall-spill), per-host states available|draining|maintenance|restoring|degraded, enforcing the R2 invariants: draining→maintenance ONLY when that host's jobs are idle; restoring→available requires health green; the fallback lease is always expiring. Each transition, in order: (a) journal intent BEFORE execution — an audit-write failure REFUSES the transition; (b) check expectedRevision — mismatch → 409 no-op; (c) check idempotencyKey — already-seen → return the prior result with NO re-exec, and replay precedence is BEFORE the revision check per the spec; (d) on success bump revision + persist atomically.

(3) Wire into controller/src/server.ts: POST /transition/:verb with body {expectedRevision:number, idempotencyKey:string, args} — responses 200 / 409 (stale revision) / 422 (bad args) / 403 exactly per the spec (coerce expectedRevision if it arrives as a string); and replace c1's stub GET /status with the REAL ControllerStatus built from the store (fill controller/src/status.ts's builder from live store state).

(4) Fail-closed config (folds R1) — controller/src/config.ts schema-validates the controller config on load: a PARSE/SCHEMA failure puts the controller in `degraded` + emits ONE incident + REFUSES to authorize any local fail-open path (NEVER silently degrade to local); a validly-disabled state is legitimate.

Acceptance: `cd controller && bun test` (whole controller suite) PASS — for EVERY transition verb: kill-mid-transition then restart → converges with no double-eligible identity, no forgotten fallback lease, no premature maintenance; stale expectedRevision → 409 no-op; replayed idempotencyKey → prior result, no re-exec; corrupt config → degraded, never local-fail-open; GET /status zod-validates against the ControllerStatus schema. AND `cd controller && pnpm typecheck` clean. Commit only: controller/src/store.ts, controller/src/store.test.ts, controller/src/transitions.ts, controller/src/transitions.test.ts, controller/src/config.ts, controller/src/config.test.ts, controller/src/status.ts, controller/src/server.ts.

verify:
cd controller && bun test
cd controller && pnpm typecheck