Worker / Agent Split#

Problem#

Today the server and executor are fused into a single process. The server receives a webhook, enqueues a QueuedRun, and an in-process tokio task pulls it from an mpsc channel and executes it — all within the same pod, the same cluster.

This is a single-site, single-process assumption. It breaks as soon as any of these are true:

  • A second deployment context exists (a different cluster, a host with Nix, a build machine with GPU access).

  • Multiple users or teams share one UI but run on different infrastructure.

  • The server runs in Kubernetes but a build needs tools that are impractical to containerize (Nix with a warm store, hardware access).

A central web UI requires all execution contexts to report back to one server. Without a network boundary between server and executor, each deployment is an island with its own UI, its own run history, its own event stream. That contradicts the multi-user product vision.

Current State#

The run queue architecture from sprint 16 already models the concepts:

  • QueuedRun is a work item carrying everything needed to execute: run ID, repo URL, ref, sha, default branch.

  • A worker task consumes from the queue, resolves workflows, and runs them via the Executor trait.

  • The EventSender (mpsc channel) streams events from executor to persistence and broadcast.

The coupling is in the transport: the worker is an in-process task pulling from a tokio mpsc channel. The split replaces that in-process channel with a network protocol.

Proposed Model#

Server responsibilities#

The server owns the control plane:

  • Identity and authorization — OAuth login, session management.

  • Run lifecycle — receives triggers (webhook, API, UI), creates QueuedRun, transitions state (queued → claimed → running → finished).

  • Event persistence — receives RunEvent from workers, writes JSONL files, updates RunStore.

  • API and UI — serves the web interface, SSE endpoints, REST API.

  • Run routing — matches queued runs to workers based on labels.

The server never executes builds.

Worker responsibilities#

The worker owns the execution plane:

  • Claims work — polls or subscribes for queued runs that match its capabilities.

  • Executes — clones the repo, discovers workflows, runs them via whichever Executor is configured (host, k8s, future nix-native).

  • Streams events — sends RunEvent back to the server as they occur.

  • Stateless — if the worker crashes, the server detects it (via heartbeat timeout) and can re-queue or mark the run as failed. No local state needs recovery.

The worker is a new binary: myci worker --server <url> --executor <type>.

runs-on as Routing Key#

The runs-on field in workflow YAML is currently ignored. With the worker split it becomes the routing mechanism.

Workers register with labels describing their capabilities:

myci worker --server https://myci.example \
    --executor host \
    --label nix=true \
    --label arch=x86_64

A workflow declares what it needs:

jobs:
  build:
    runs-on: [nix]
    steps:
      - run: nix build .#my-image

The server matches the runs-on labels against registered workers and routes the run accordingly. If no worker matches, the run stays queued.

What Changes#

  • Work claim API: POST /api/worker/claim — worker polls for queued runs matching its labels. Alternative: WebSocket subscription for push-based delivery.

  • Event push: worker sends RunEvent back to server. Could reuse the existing event types over HTTP (batched POST) or WebSocket.

  • Worker binary: myci worker — connects to server, claims work, executes, streams events.

  • Run routing: server matches runs-on labels to worker capabilities.

  • Heartbeat / liveness: server detects dead workers and re-queues or fails their runs.

What Stays the Same#

  • Executor trait, StepRunner, RunContext — unchanged. The worker instantiates the executor locally, same as today.

  • RunEvent / EventKind — same types, different transport (network instead of in-process channel).

  • myci run (local, no server) — unchanged. Still a self-contained path for local development.

  • Workflow format, expression evaluation, action resolution — all library code, used by both server and worker.

Connection to Federated Vision#

The worker split is the concrete first step toward a federated platform vision where MyCI instances act as sovereign peers in a network — receiving events via open protocols, executing locally, and reporting back.

The worker model makes this possible: each worker is a peer that connects to a server (or eventually to a federated event bus). The server is one coordination point today; it could become one of many in a federated setup. The architecture does not assume a single server — it assumes a protocol between coordinators and executors.

Longer-term directions (Matrix as event bus, CRDT-based state sync, overlay networking) build on this foundation but are not prerequisites for the worker split itself.

Migration Path#

The split does not need to happen all at once:

  1. Extract the worker loop — move the in-process worker task into a function that can be called either in-process or from a standalone binary. Same code, two entry points.

  2. Add the claim/event APIs — server exposes work items, accepts events. The in-process path remains as a fallback.

  3. Worker binarymyci worker as a thin wrapper around the extracted loop plus HTTP client for claim/event.

  4. Deprecate in-process execution — once the worker protocol is stable, the server drops the built-in executor.