This is the "now add a 2nd+ compute agent, cost-tiered" operator guide. Where
cloud-burst.md walks through provisioning a single OCI A1 arm64 compute
agent end-to-end (host, Tailscale ACL, broker role, -arm64 image, smoke test), this page is the
next step: declaring two or more kind="compute" backends at different cost tiers so the
scheduler prefers the cheap one and spills to the pricier one under load β a free arm64 A1 that
fills first, a paid/trial x86 box that only takes overflow, and local as the final safety net.
Everything here runs on the already-shipped Phase 72/73 machinery (per-entry agent_ref binding,
per-agent push/scratch destination, rank/cap load-spread, per-backend failure isolation). There is
no new routing behavior β this is the operator recipe for using it with N compute agents.
The
[[backends]]field schema is not restated here. For the canonical per-field reference (id/kind/rank/cap/agent_ref/scratch_dir/push_host/ssh_userand the startup validators), see configuration.md β Backend registry. This page shows the worked scenario; that page is the field table.
Each long file drains rank-first: the lowest-rank compute lane with a free slot wins, spills
to the next rank when the preferred lane is at cap or offline, and only falls to local under the
staleness gate (or immediately when every cloud lane is offline).
flowchart TD
cand["long file (cloud_job status='awaiting')<br/>duration β₯ PHAZE_CLOUD_ROUTE_THRESHOLD_SEC"] --> sel["select_backend policy<br/>(rank-first, cap-aware, per candidate)"]
sel -->|"1st choice β cheapest"| a1["compute Β· a1-arm64<br/>RANK 10 Β· cap 2<br/>free OCI A1 (arm64)"]
sel -.->|"spill when a1-arm64 is at cap or offline"| x86["compute Β· x86-spill<br/>RANK 20 Β· cap 4<br/>paid/trial x86 box"]
sel -.->|"staleness-gated (all cloud FULL, waited)<br/>OR immediate (all cloud OFFLINE)"| local["local<br/>RANK 99 Β· cap 1<br/>file-server safety net"]
a1 -->|"rsync-over-SSH push β a1's scratch_dir"| a1host["A1 host<br/>PHAZE_AGENT_QUEUE=phaze-agent-a1-arm64"]
x86 -->|"rsync-over-SSH push β x86's scratch_dir"| x86host["x86 host<br/>PHAZE_AGENT_QUEUE=phaze-agent-x86-spill"]
Two compute lanes at distinct ranks give you cost-tiering for free: the drain always tries
rank 10 (free arm64) first and only reaches rank 20 (paid x86) when the arm64 lane is full or
offline. local at rank 99 is the last-resort catch β see
runbook.md β Spillover behavior for the exact staleness/attempt
gates.
Declare both compute agents in the registry TOML (PHAZE_BACKENDS_CONFIG_FILE, default
/etc/phaze/backends.toml) alongside the always-present local catch. Each compute entry binds to
its own registered agent (agent_ref), pushes to its own host (push_host), and lands in
its own scratch dir β the two lanes never share a destination.
# /etc/phaze/backends.toml on the control plane (lux).
# Two compute lanes at different cost tiers + the local safety net.
# --- Tier 1: free arm64 OCI A1 β preferred, fills first ---
[[backends]]
kind = "compute"
id = "a1-arm64"
rank = 10 # lowest rank = dispatched first (cheapest)
cap = 2 # 2 OCPU / 12 GB Ampere A1 (RAM-bound; keep small)
agent_ref = "a1-arm64" # REQUIRED β names THIS lane's registered compute Agent.id
push_host = "a1-arm64" # REQUIRED β rsync/ssh destination host for this lane
scratch_dir = "/var/lib/phaze/scratch" # REQUIRED β ephemeral push landing dir
ssh_user = "phaze" # optional β falls back to the fileserver's configured user
# --- Tier 2: paid/trial x86 β spill target, only fills after arm64 ---
[[backends]]
kind = "compute"
id = "x86-spill"
rank = 20 # only reached when a1-arm64 is at cap or offline
cap = 4 # a bigger x86 box can take more concurrent analyses
agent_ref = "x86-spill" # REQUIRED β MUST be distinct from a1-arm64 (dup agent_ref fails at boot)
push_host = "x86-spill" # distinct host from the A1
scratch_dir = "/var/lib/phaze/scratch"
ssh_user = "phaze"
# --- Final catch: local file server ---
[[backends]]
kind = "local"
id = "local"
rank = 99 # last-resort spill; never excluded
cap = 1Distinct
agent_refper lane is load-bearing. Two compute backends sharing anagent_reffail fast at boot β each lane must bind to its own registeredAgent.id. The registry is startup-read: edit the TOML, then restart the control-plane worker + api for it to take effect. An absentbackends.tomlsynthesizes an implicit singlelocalbackend (all-local).
Ranks encode your cost preference; the scheduler always drains lowest rank first and spills upward.
| Tier | Backend | Rank | Cap | Cost posture |
|---|---|---|---|---|
| 1 (preferred) | a1-arm64 (free OCI Ampere A1) |
10 |
2 |
Always-free β fill this first. Small cap because the 2 OCPU / 12 GB A1 is RAM-bound on long sets. |
| 2 (spill) | x86-spill (paid/trial x86 box) |
20 |
4 |
Paid or trial β only takes overflow when the free lane is at cap or offline. Larger cap; costs money per running hour, so it idles unless tier 1 is saturated. |
| final catch | local (file server) |
99 |
1 |
No marginal cost, but slow β the guaranteed safety net. Reached only under the staleness gate (all cloud full and the file has waited) or immediately when every cloud lane is offline. |
Raise a lane's rank to make it less preferred; lower it to make it more preferred. Two lanes
at the same rank tie-break by utilization first (in_flight / cap, least-loaded wins), then
by stable lexicographic id β the SCHED-04 sort key is (rank, utilization, id) (see
runbook.md β Spillover behavior).
There is one compute-agent compose file β docker-compose.cloud-agent.yml.
You run it once per compute agent, on that agent's own host, with a distinct identity each time.
Follow the same per-agent env convention as cloud-burst.md β Step 5
and deployment.md β Step 4: there is no
bare AGENT_ID compose variable β PHAZE_AGENT_ID is only the documentation mnemonic for the
<id> that feeds PHAZE_AGENT_QUEUE=phaze-agent-<id> (the SAQ queue the worker consumes) and the
backend's agent_ref.
Give each agent its own:
| Per-agent setting | a1-arm64 example |
x86-spill example |
|---|---|---|
PHAZE_AGENT_QUEUE |
phaze-agent-a1-arm64 |
phaze-agent-x86-spill |
backend agent_ref (in backends.toml) |
a1-arm64 |
x86-spill |
PHAZE_CLOUD_SCRATCH_DIR + scratch volume |
/var/lib/phaze/scratch |
/var/lib/phaze/scratch (separate host) |
SSH push host (push_host) |
a1-arm64 |
x86-spill |
compose project name (-p) |
-p phaze-a1 |
-p phaze-x86 |
PHAZE_AGENT_QUEUE MUST equal phaze-agent-<PHAZE_AGENT_ID> β the worker derives the expected
queue name from its token's agent id and exits non-zero on mismatch.
The two agents run different images with different launch commands β the single most important difference between an arm64 and an x86 compute agent:
| arm64 A1 agent (default) | x86 spill agent (override) | |
|---|---|---|
| Image | the -arm64 tag (default) β e.g. β¦/phaze:<tag>-arm64 |
override PHAZE_CLOUD_AGENT_IMAGE to the standard x86 tag (NO -arm64 suffix) |
| Command | python3 -m saq phaze.tasks.agent_worker.settings (arm64 image is Python 3.13 + --system, no .venv) |
override PHAZE_CLOUD_AGENT_CMD to uv run saq phaze.tasks.agent_worker.settings (x86 image is Python 3.14 with a .venv) |
Do not copy uv run β¦ onto the arm64 agent β that image has no .venv and uv run
re-validates requires-python >=3.14, so the container fails to boot. Conversely the x86 image
must not pull the -arm64 tag (there is no multi-arch manifest). Set PHAZE_CLOUD_AGENT_IMAGE
PHAZE_CLOUD_AGENT_CMDon the x86 host and leave both at their arm64 defaults on the A1.
Co-located agents collide on scratch + project name. In this worked example the two agents live on different hosts (a free A1 and a paid x86 box), so host isolation makes this a non-issue. If you ever run two compute agents on one host, give each a distinct
-p <project>compose project name, a distinctPHAZE_CLOUD_SCRATCH_DIR/ scratch volume, and a distinctPHAZE_AGENT_QUEUEβ otherwise the second agent shares or steals the first's scratch volume.
Never inline a token, SSH key, or DATABASE_URL in any example. Use the existing *_FILE
pointers only (PHAZE_QUEUE_URL_FILE, PHAZE_AGENT_TOKEN_FILE, PHAZE_PUSH_*_FILE) read via
env_file: .env β exactly as cloud-burst.md documents. The compute agent reaches
Postgres only via PHAZE_QUEUE_URL for the saq_jobs broker plus the HTTP API β never the app
ORM (DIST-04).
Once both compute agents are declared and online, the Analyze workspace renders one lane card
per backend β you will see three cards: COMPUTE Β· a1-arm64 (RANK 10), COMPUTE Β· x86-spill
(RANK 20), and LOCAL Β· local (RANK 99), sorted rank-ascending left-to-right so the top-left
lane is what the scheduler uses first. Each card shows its {in_flight}/{cap} capacity numeral and,
when a probe fails for a poll, a greyed glyph + the word offline (that lane only β a single
failing backend never stalls the rest of the grid).
For the full read-out vocabulary β RANK {n}, {in_flight}/{cap}, offline, and the Kueue
quota-wait/inadmissible distinction β see
runbook.md β Reading the N lanes. The lane grid and this doc use
the same words on purpose.
- cloud-burst.md β provisioning a single OCI A1 compute agent end-to-end (the walkthrough this page builds on).
- configuration.md β Backend registry β the
canonical
[[backends]]field reference (not restated here). - runbook.md β force-local revert, reading the N lanes, and spillover behavior.
- k8s-burst.md β the Kubernetes (Kueue) lane, if you also declare
kind="kueue"backends alongside your compute agents.