Skip to content

Deploy

How a worker is provisioned and boots. The worker is the agent's hands — an on-demand worker (bash · fs · browser) realized on Kubernetes as a short-lived Job, never a long-running Deployment. Nothing is always-on per agent.

This is step 1 of the lifecycle → then Use → then Destroy.

Who creates it

The worker app does not schedule itself. The runtime manager inside api (the runtime/worker slice) owns the k8s client and the session lifecycle:

orchestrator → tasks (BullMQ) → runtime manager → k8s Job (worker pod)
                                     │ mints a short-lived session token (user/auth)
                                     │ builds the manifest from a RuntimeProfile

                             worker pod boots → WS to Core → ready → runs → uploads → exits
                                     │ heartbeats reset the idle timer

                             idle reaper deletes the Job (see Destroy)

Only "the agent needs hands" is queued

A plain chat turn with no tools never touches the queue (the runtime/task slice). Only worker/tool tasks are enqueued:

message → orchestrator (load agent + memory + chat, tool-analysis)
   ├─ no tools  → llm.stream() answers directly → save → done      ← NO queue
   └─ tools     → tasks (queue) → dispatcher → worker Job → stream  ← queue

The queue earns its place for backpressure + timers + reliability — not raw throughput:

PurposeWhat it doesWhat breaks without it
Backpressuremeters Job creation to node capacitya spike of N tasks → N Pending pods → scheduler/etcd pressure
Concurrency / fairnessper-team caps, global ceilings, premium priorityone tenant starves everyone
Reliabilityretry-with-backoff + dead-lettera lost task just disappears
Timersdelayed jobs = idle-reaper; repeatable = agent/cronno clean idle-teardown or scheduled runs
Decouplingtask survives which stateless api replica enqueued itan HPA rescale drops in-flight work

The consumer is the dispatcher

Workers are ephemeral — one k8s Job per task — so the queue consumer is not a worker pool. It's the dispatcher (the runtime/worker manager in api): it pulls a task, applies concurrency caps, picks a RuntimeProfile, and creates the Job. The worker is spawned per task and streams results back over events (Redis pub/sub → SSE/WS); it is never itself a queue consumer.

Job kindMechanismUsed for
Task dispatchstandard jobrun a tool obligation in a worker Job
Idle timersdelayed jobreaper sleeps/tears down an idle session (Destroy)
Scheduled / cronrepeatable jobagent/cron runs → same orchestrator path
Ingestionstandard job (budgeted)LightRAG document indexing

Engine: BullMQ on Redis (gives delayed + repeatable jobs and the pub/sub for events). Open alternative — a Postgres-only pg-boss + LISTEN/NOTIFY — kept behind the runtime/task slice so the engine is swappable. See Task-queue decisions.

Runtime modes → k8s primitives

Resource shape comes from a RuntimeProfile (a preset catalog):

ModeTriggerk8s primitiveResourcesIdle
Noneplain chat, no toolsno pod (LLM + memory only)n/a
Lightbash, scripts, file opsJob (restartPolicy: Never, ttlSecondsAfterFinished)~0.5 CPU / 512Mi60–120s
Browserbrowser tool usedJob with in-pod headless Chromium (Playwright, per-tenant --user-data-dir)per taskuntil browser idle
Heavyscraping, big computeJob on the workers nodepool, high limits2+ CPU / 2Gi+30–60s
WarmpremiumLight/Heavy Job kept alive past idleper-profile10–30 min

The Job manifest (illustrative)

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: agent-{sessionId}
  namespace: tenant-{teamId}            # namespace (or labels) per tenant
  labels: { app: agentfy-worker, team: "{teamId}", session: "{sessionId}" }
spec:
  backoffLimit: 0                       # no retries — a failed obligation fails the task
  ttlSecondsAfterFinished: 60           # k8s auto-deletes the finished Job
  activeDeadlineSeconds: 1020           # dialSeconds + maxExecSeconds — NOT maxExecSeconds alone:
                                        # k8s counts this from the JOB's start, so it contains the
                                        # cold start too. See /worker/protocol#deadlines
  template:
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      nodeSelector: { node-role: workers }
      tolerations: [{ key: node-role, value: workers, effect: NoSchedule }]
      # `fsGroup` is what makes the 0440 pass file readable by the image's
      # `USER 1000:1000`: a Secret volume's files are owned by root.
      securityContext: { runAsNonRoot: true, fsGroup: 1000, seccompProfile: { type: RuntimeDefault } }
      containers:
        - name: worker
          image: registry/agentfy-worker:{tag}
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { cpu: "1", memory: "1Gi", ephemeral-storage: "5Gi" }
          env:
            - { name: SESSION_ID,  value: "{sessionId}" }
            - { name: CONTROL_URL, value: "wss://core/ws/runtime" }
            - { name: TOOL_ALLOWLIST, value: "bash,fs,browser" }
            # Where `http` / `web_fetch` may go. Hosts, or `*.host` — never a URL and
            # never `*`. ABSENT MEANS EMPTY, and empty reaches nowhere.
            - { name: EGRESS_ALLOWLIST, value: "example.com,*.example.com" }
            # Where `browser_play` may TYPE and PRESS (AGNT2-221). A SECOND list,
            # not a reading of the one above: being allowed to read a site is not
            # permission to press things on it. Exact hostnames only — no `*.`
            # and no `internet:unrestricted`. ABSENT MEANS EMPTY, and empty
            # touches nothing.
            - { name: BROWSER_PLAY_ALLOWLIST, value: "shop.example.com" }
            # The pass arrives as a FILE, never as a value in the environment
            # (006 T104). This names the PATH; the Secret behind it is owned by
            # this Job, so the cluster reaps it when the Job goes.
            - { name: WORKER_TOKEN_FILE, value: /var/run/agentfy/token }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
            - { name: session-pass, mountPath: /var/run/agentfy, readOnly: true }
          securityContext:
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            capabilities: { drop: ["ALL"] }
      volumes:
        - name: workspace
          emptyDir: { sizeLimit: "5Gi" }                 # ephemeral; wiped when the pod dies
        - name: session-pass
          projected:
            defaultMode: 0440                            # root:1000 — readable by the pod's user
            sources:
              - secret:
                  name: worker-{jobName}-pass
                  items: [{ key: token, path: token }]

Boot & registration

The pod boots with SESSION_ID, CONTROL_URL, a short-lived session token (JWT scoped to this one AgentRuntimeSession, injected as a projected/short-TTL Secret — no long-lived secrets in the pod), a tool allowlist, and a storage scope. It then dials back to Core over WebSocket and signals ready. The full handshake and message protocol live in Use; the wire contract behind them — every frame, its answer, its timeout and the version rule — is Tool channel.

Scaling

  • Scale-to-zero is intrinsic: a Job exists only while a task runs, then is deleted. No KEDA/Knative needed for MVP.
  • Concurrency is bounded by the workers nodepool + per-tenant ResourceQuota/LimitRange; the cluster-autoscaler grows the nodepool under load.
  • The worker bundles Chromium → browser cold-start = Job spin-up + Chrome launch. Pre-pulled worker images (and a couple of warm workers nodes / a Warm profile) hide that latency.

Security checklist

  • Namespace (or NetworkPolicy + labels) per tenant/session.
  • NetworkPolicy egress allowlist (deny-all by default; allow Core WS + required domains).
  • Profile CPU/RAM/storage limits; read-only root fs, drop all capabilities, non-root, automountServiceAccountToken: false.
  • Temp emptyDir workspace only; no permanent secrets in the image; creds short-lived, expire with the session.
  • activeDeadlineSeconds hard cap on runaway runtimes — sized dialSeconds + maxExecSeconds, because k8s starts counting at the Job, not at the process (tool channel); audit log every session via admin/audit.

Where the code lives

  • Manager (control): apiruntime/worker slice — sessions · k8s (manifest builder + create/delete) · browser (in-pod Chromium lifecycle) · idle (reaper) · profiles.
  • Queue: apiruntime/task slice (BullMQ).
  • Worker (the image): the top-level worker app — agent loop + bash/fs/browser executors + WS client. Borrow tool-executors from cleanslice/runtime, k8s/manifest patterns from Ranch.
  • Cluster: Hetzner k8s, workers nodepool (tainted). See Resources.

See also

  • Use — how the brain drives the live worker (sessions, protocol, tools).
  • Tool channel — the wire contract: frames, answers, silence, versions.
  • Destroy — idle reaper, release, TTL and cleanup.
  • Implementation — the build prompt for this subsystem.
  • Cluster & nodes — backpressure + the autoscaler the queue feeds.