Personal infrastructure project

Fleet Runner — architecture & build plan

One set of rails that turns a shelf of iOS and Android devices into a programmable fleet: on-device ML benchmarks, Maestro UI-test runs of GreenFolio across real devices, nightly Jerv battery-drain tests, and batch inference — all dispatched from one collector, all landing in one results database.

Build status — 2026-08-19 · every planned phase built; first product eval delivered; the collector now has a real dashboard

01Goals and non-goals

Goals. Two families of workloads on shared infrastructure:

Both families share the collector, the job/result protocol, the device registry, the scheduler, and the dashboard. The fleet is programmable: a new capability is a new workload type, not a new system.

Non-goals (v1). No training on device, no model conversion on device, no public multi-tenant anything, no UI beyond a status screen on the phone and a plain dashboard on the collector. Old devices that can't run LLMs still earn their place — they're the UI-test matrix, the OEM-task-killer soak targets, and the vision/audio workload pool.

02System overview

Fleet — runner app on each device

iOS · Core ML / llama.cppnewer iPhones
iOS · Core ML onlyolder iPhones / iPads
Android · LiteRT / llama.cppnewer Androids
Android · LiteRT onlyolder Androids
Tailscale mesh · agents poll over HTTP · host executor drives devices over USB / Wi-Fi
Collector single service on the Mac mini
Device registry
Job queue + locks
Scheduler (cron)
Artifact store
Results DB (SQLite)
Dashboard
Power control
Host executor worker on the Mac
adb / devicectl
Maestro
XCUITest
app install

Four pieces: the runner app (on every device), the host executor (a worker on the Mac that drives devices from outside), the collector (one small service: queue, registry, scheduler, results), and the artifact store (models and app builds, served by hash). Everything rides your Tailscale network, so the fleet works whether devices are on your desk or plugged in around the house — with the one caveat that host-driven jobs need the device reachable by the executor (USB hub or Wi-Fi adb).

03Two execution paths — the key architectural split

Not every workload can run inside an app on the device. iOS will not let an app install another app or drive its UI, and a battery test of Jerv must run Jerv itself, not a proxy. So every job declares its executor:

Decision — the runner app doubles as a telemetry beacon

During host-driven jobs the runner app (or a headless companion) keeps sampling battery, thermal state, and memory pressure and streams it to the collector. Host-driven tests get the same metrics timeline as on-device ML jobs — one results schema, one dashboard. On Android the beacon also reports "is process X alive?", which is the entire measurement for OEM-task-killer soaks.

Decision — host executor runs on a Mac, jobs can come from anywhere

iOS tooling (devicectl, XCUITest) requires macOS, so the host executor lives on the Mac mini next to the collector. Your CI runners don't drive phones — they enqueue fleet jobs over Tailscale and read results back, so a GitHub Actions workflow can gate a PR on a real-device run without touching a device.

04The runner app

Two thin native apps — Kotlin on Android, Swift on iOS — sharing a protocol, not code. The contract is the JSON job/result schema plus an internal interface both implementations mirror:

ModelBackend            // one per inference engine
  capabilities() -> [formats, accelerators, maxModelSize]
  load(artifact)  -> LoadedModel   (timed: load_ms)
  run(input)      -> Output        (timed per call)
  unload()

WorkloadEngine          // interprets device-executor job specs
  benchmark   : warmup N, measure M iterations, sustained-load option
  batch       : pull items from collector, run, push outputs
  pipeline    : subscribe to trigger topic, run on event, publish result

TelemetryBeacon         // always-on, also during host-driven jobs
  battery, thermal, memory pressure, process-alive checks

Reporter                // ships results + beacon stream, retries offline
Decision — no shared-code framework yet

Start with two native apps against a shared JSON protocol. The runner's core logic is small; Kotlin Multiplatform is a later refactor if the duplicated logic starts to bite. This keeps weekend one about inference, not build systems.

Decision — llama.cpp on both platforms for LLMs

Same engine, same GGUF file, same quantization on iOS and Android makes cross-platform numbers apples-to-apples at the model level. Core ML vs LiteRT comparisons are engine-vs-engine claims; llama.cpp-vs-llama.cpp is hardware-vs-hardware.

05Workload catalog

WorkloadExecutorWhat it doesPrimary use
benchmarkdeviceWarmup + measured inference iterations, optional sustained-loadModel/quant selection, min-spec decisions, cross-generation report
batchdevicePull items, run inference, push outputsTranscription backlog, photo-library embeddings
pipelinedeviceSubscribe to MQTT topic, run on event, publishTiered home pipelines (old phone filters → new phone escalates)
installhostInstall APK/IPA from artifact store, verify launchPrecondition for every app-testing job
ui-testhostRun a Maestro flow (or XCUITest bundle) against an installed build, capture screenshots/video/JUnit resultsGreenFolio regression suite across six real devices
drainhost + beaconLaunch app in a scenario (e.g. tracking with a replayed route), run unplugged from a fixed battery %, record the drain curveNightly Jerv tracking-drain test
soakhost + beaconInstall, start app, leave overnight; beacon reports process-alive + battery hourlyOEM task-killer survival matrix (Samsung, Xiaomi…)

ML backends per platform: LLMs via llama.cpp (GGUF, Metal on iOS); vision/audio via Core ML (.mlmodelc) and LiteRT (.tflite); Whisper via whisper.cpp. Conversion happens on the Mac, never on device. The artifact store holds models and app builds alike: name, kind, format, sha256, min-RAM hint.

06Job and result protocol

The whole system hinges on two JSON documents. An ML benchmark job:

{
  "job_id": "bench-qwen3-0.6b-q4-2026-08-13",
  "workload": "benchmark", "executor": "device",
  "model": { "name": "qwen3-0.6b", "format": "gguf", "quant": "Q4_K_M",
             "sha256": "…" },
  "backend": "llama.cpp",
  "params": { "prompt_tokens": 512, "gen_tokens": 128,
              "warmup_iters": 2, "measure_iters": 5 },
  "targets": { "match": "ram_mb >= 4000" },
  "constraints": { "require_charging": true }
}

A UI-test job rides the same schema — different workload, different executor, an app build instead of a model:

{
  "job_id": "gf-maestro-smoke-pr412",
  "workload": "ui-test", "executor": "host",
  "app":  { "name": "greenfolio-android", "build": "1.4.0-pr412",
            "sha256": "…" },
  "suite": { "kind": "maestro", "flows": "flows/smoke/*.yaml" },
  "targets": { "pool": "android-ui", "exclusive": true },
  "report_to": { "github_status": "addisdev/greenfolio-android@abc123" }
}

Results are rows in one table: metrics for benchmarks (prefill/decode tok/s, ttft, peak memory + method, thermal samples, battery delta), pass/fail + screenshot/video artifact refs for UI tests, and timestamped beacon curves for drain and soak runs. Version the schema from day one ("schema": 1), and make result posting idempotent by (job_id, device_id, iter).

07Metric normalization — the honest-numbers problem

08The collector

One small TypeScript service (Node + Fastify) with SQLite — no external dependencies, runs forever on the Mac mini under launchd. Endpoints:

POST /devices/register        device checks in with descriptor + pools/tags
GET  /devices/:id/next-job    long-poll for device-executor work
GET  /executor/next-job       long-poll for host-executor work
GET  /artifacts/:sha256       models and app builds (range requests)
POST /results                 result rows + beacon samples, idempotent
POST /jobs                    enqueue (curl, CI, or the scheduler)
GET  /dash                    server-rendered dashboard

The dashboard starts as one page: device grid (last seen, battery, thermal, current job), job history with pass/fail, per-model comparison table, and drain-curve charts. Resist building more until the data demands it.

09What each app runs on the fleet

GreenFolio shipped · iOS + Android + web

Aliquant pre-release · iOS first

Jerv in development · iOS first

09bEval delivered: GreenFolio on-device plant ID

The fleet's first product payload. GreenFolio identifies plants today by sending photos to the cloud Plant.id API; the question was whether species identification can run on-device — offline in a greenhouse, at zero per-call cost — and on what hardware. Full method and per-image reports live in fleet-collector/evals/greenfolio-plant-id.md.

Method. 120 held-out images (16 species) sampled from the PlantNet-300K test split, center-cropped and resized once on the Mac so every device classifies identical bytes; ImageNet normalization on-device. Candidates: the Apache-2.0 litert-community PlantNet-300K ResNet18 (1081 species), our own post-training int8 quantization of it (100 validation-split calibration images — disjoint from the eval — float I/O preserved), and a 47-class houseplants model as a latency comparator.

ModelSizeTop-1Top-5p50 latencyp95
PlantNet ResNet18 fp3247 MB77.5%90.0%54 ms57 ms
PlantNet ResNet18 int812 MB76.7%88.3%11 ms12 ms
houseplants-47 (comparator)30 MBn/a — different label space69 ms77 ms
iOS · Core ML fp16 (same weights)23.5 MB76.7%90.0%8.4 ms13.5 ms
iOS · Core ML int8-weight11.8 MB75.8%90.8%7.6 ms11.6 ms

Android rows: ATD emulator, CPU, 4 threads. iOS rows: iPhone 16 simulator on CPU — the Simulator's emulated GPU/ANE silently returned all-zero logits for this model, so the iOS runner forces CPU on sims and labels it; real iPhones use the ANE. Emulator/simulator numbers validate the pipelines and the relative comparison; the SM-X930 rows are queued via fan-out and fill in when the tablet wakes. Host fp32 accuracy matched device fp32 exactly, and Core ML matches LiteRT within a point on identical images — both platforms' preprocessing is faithful to the same weights.

Verdict — ship int8, top-5 as the product surface

4× smaller and 5× faster for under a point of top-1 and under two of top-5. 12 MB is an in-app asset, not a download; 11 ms means a live viewfinder works on CPU alone, sidestepping GPU-delegate flakiness entirely. Recommended shape: on-device int8 top-5 as a "did you mean…" list (turns 77% top-1 into an ~88% "it was in the list" experience), cloud Plant.id only below a confidence threshold. Min-spec floor gets decided by the shelf fan-out as devices come online — the eval re-runs weekly once schedules are enabled.

10Build plan

Phase 01 weekenddone

Protocol + collector skeleton

Job/result schemas with the executor field from day one. Collector with registry, queue, artifact store (models + app builds), results table, minimal dashboard. Fake both executor types with curl.

a curl-simulated device and a curl-simulated host executor can each claim jobs, fetch artifacts, and post results that appear on the dashboard.

Phase 12 weekendsdone

Android ML runner MVP

Kotlin app, foreground service, agent loop + telemetry beacon, llama.cpp backend, benchmark workload. Android first: adb makes debugging painless and your oldest devices are Android.

two Android devices run the same GGUF benchmark and the dashboard shows a comparison row.

Phase 21 weekenddone

Host executor + Maestro on Android

The Mac-side worker: claim host jobs, install workload (APK from artifact store), ui-test workload running Maestro flows over adb, screenshots/JUnit uploaded as result artifacts. Wire the GreenFolio smoke flows first.

one command runs the GreenFolio smoke suite across every device in the android-ui pool and the dashboard shows per-device pass/fail with screenshots.

Phase 31–2 weekendsdone*

iOS: runner app + host-driven testing

Swift runner mirroring the protocol (llama.cpp via xcframework, then Core ML), distributed via TestFlight internal. Host executor grows devicectl install + XCUITest; Maestro proved not stable on iOS under Xcode 26.6, so XCUITest it is (*the remaining gap, with real-iPhone devicectl).

the same benchmark fans out across iOS and Android into one table ✓, iOS UI tests run via the XCUITest bundle ✓, real iPhones enumerated via devicectl ✓ (a signed build is the app repo's job) — *Maestro-on-iOS remains unusable under Xcode 26.6, and the iOS 27 beta runtime crashes XCUITest runners; fleet iOS UI tests target stable runtimes.

Phase 41–2 weekendsdone*

Scheduler, power control, battery workloads

Cron-style scheduled enqueues, smart-plug power control, device pools + exclusive locks. Then the drain workload (GPX replay hook in the Jerv debug build) and the soak workload (process-alive beacon checks).

scheduler, fan-out, locks, power webhooks, soak, and drain (GPX replay via simctl/adb emu, battery curve, lease-renewing checks) all built and verified through the queue ✓ — *a real Jerv drain number still needs a physical device unplugged (smart plugs make that repeatable) and Jerv's own debug replay provider on physical iOS.

Phase 5ongoingbuilt · off

CI integration + new workloads on the same rails

GitHub Actions enqueue + commit-status reporting: real-device smoke on PRs, full matrix nightly. Then the ML expansion pack on the same rails: vision backends for old devices, sustained-thermal benchmark, batch transcription/embeddings, MQTT pipeline nodes.

the enqueue script and commit-status reporting exist and pass dry-run verification ✓ — deliberately disconnected from every app repo and CI system until the owner flips it on (two env vars + one workflow).

11Fleet care (do these early)