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 ← queueThe queue earns its place for backpressure + timers + reliability — not raw throughput:
| Purpose | What it does | What breaks without it |
|---|---|---|
| Backpressure | meters Job creation to node capacity | a spike of N tasks → N Pending pods → scheduler/etcd pressure |
| Concurrency / fairness | per-team caps, global ceilings, premium priority | one tenant starves everyone |
| Reliability | retry-with-backoff + dead-letter | a lost task just disappears |
| Timers | delayed jobs = idle-reaper; repeatable = agent/cron | no clean idle-teardown or scheduled runs |
| Decoupling | task survives which stateless api replica enqueued it | an 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 kind | Mechanism | Used for |
|---|---|---|
| Task dispatch | standard job | run a tool obligation in a worker Job |
| Idle timers | delayed job | reaper sleeps/tears down an idle session (Destroy) |
| Scheduled / cron | repeatable job | agent/cron runs → same orchestrator path |
| Ingestion | standard job (budgeted) | LightRAG document indexing |
Engine: BullMQ on Redis (gives delayed + repeatable jobs and the pub/sub for
events). Open alternative — a Postgres-onlypg-boss+LISTEN/NOTIFY— kept behind theruntime/taskslice so the engine is swappable. See Task-queue decisions.
Runtime modes → k8s primitives
Resource shape comes from a RuntimeProfile (a preset catalog):
| Mode | Trigger | k8s primitive | Resources | Idle |
|---|---|---|---|---|
| None | plain chat, no tools | no pod (LLM + memory only) | — | n/a |
| Light | bash, scripts, file ops | Job (restartPolicy: Never, ttlSecondsAfterFinished) | ~0.5 CPU / 512Mi | 60–120s |
| Browser | browser tool used | Job with in-pod headless Chromium (Playwright, per-tenant --user-data-dir) | per task | until browser idle |
| Heavy | scraping, big compute | Job on the workers nodepool, high limits | 2+ CPU / 2Gi+ | 30–60s |
| Warm | premium | Light/Heavy Job kept alive past idle | per-profile | 10–30 min |
The Job manifest (illustrative)
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
workersnodepool + 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
workersnodes / 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
emptyDirworkspace only; no permanent secrets in the image; creds short-lived, expire with the session. activeDeadlineSecondshard cap on runaway runtimes — sizeddialSeconds + maxExecSeconds, because k8s starts counting at the Job, not at the process (tool channel); audit log every session viaadmin/audit.
Where the code lives
- Manager (control):
api→runtime/workerslice —sessions·k8s(manifest builder + create/delete) ·browser(in-pod Chromium lifecycle) ·idle(reaper) ·profiles. - Queue:
api→runtime/taskslice (BullMQ). - Worker (the image): the top-level
workerapp — agent loop + bash/fs/browser executors + WS client. Borrow tool-executors fromcleanslice/runtime, k8s/manifest patterns from Ranch. - Cluster: Hetzner k8s,
workersnodepool (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.