condensate

How it works

Where agents run

The Mac or a Kubernetes node: how each agent is placed, sized and measured, and why.

On this page

Condensate runs each agent in one of two ways: as a process on the Mac named gt-review, or as a pod on a Kubernetes node on another Mac. The runtime is the agents-host service that runs and stores agent threads, and it picks the machine each time an agent starts. It measures how much CPU and memory each kind of agent uses, ranks the nodes by how much free space each would keep, and records why it chose the one it did.

The Mac named gt-review
  the runtime (agents-host, port 4741)
    - starts every agent and writes its log
    - runs agents placed as local processes
    - runs the Claude Code SDK and the hooks of every pod agent
    - holds the heavy-command queue for the Mac
  k3s-server, a VM: the Kubernetes control plane, which runs no agents
          |                                  |
          |  kubectl exec                    |  kubectl exec
          v                                  v
  k3s-worker-m5, on the M5             k3s-worker, a VM on the M3 Max
    agent pods                           agent pods
    the heavy-command queue for          the heavy-command queue for
    this node                            this node
  • Review agents go to a node without being asked. Every other agent runs on the Mac, unless its start request names the Kubernetes runner.
  • A node takes an agent only when it is Ready, not cordoned, answers from its kubelet, and has free space for the agent's measured size.
  • The node that keeps the most of itself free wins. A node more than 300 ms away ranks after every node that is closer.
  • A review agent that no node takes runs on the Mac, and its placement reason says why.
  • GET /live shows each agent's runner, its node, its placement reason, and the full ranking.

A node is one machine in the Kubernetes cluster. A pod is the group of containers one agent runs in on a node. A place is anywhere an agent can run: the Mac, or one node.

The machines

PlaceWhat it isCores and memory for agentsRound trip from the Mac, as a 95th percentile
headThe Mac named gt-review. Agents run on it as local processes.10 cores, 64 GiB0 ms
k3s-worker-m5A Kubernetes node on the M5.12 cores, 31.4 GiB12 ms to 114 ms
k3s-workerA Kubernetes node in a VM on the M3 Max.14 cores, 31.4 GiB257 ms to 310 ms

GET /placement read the cores and memory on 2026-10-19. The round trips are the readings that PRs #2493 and #2498 recorded on the same day. The Mac's place is named head in every API answer. k3s-server, the Kubernetes control plane, runs on the Mac and carries no agent label, so it takes no agents.

The M3 Max's link is slow and varies. On 2026-10-19 it read from 128 ms to 388 ms per connect, and the runtime's log on the Mac recorded one reading of 903 ms. So with both nodes empty, which one takes an agent can change from one minute to the next.

How an agent is placed

The runtime checks these conditions in this order. The first one that matches decides where the agent runs, and its name is the agent's placement value.

CheckWhere the agent runsplacementWhat placementDetail holds
The start request names a runner: process, k8s or containerThat runnerhintThe ranking, when the runner is k8s
The agent is not a review agentThe MacroleNothing
CONDENSATE_KUBECONFIG is not setThe Macnot-configuredNothing
The Kubernetes runner would refuse the agentThe MacrefusedThe refusal
No branch on origin holds the checkout's current commit, so a pod cannot clone itThe Machead-not-on-originNothing
The check of the nodes found no node to run onThe Macprobe-failedEach node it skipped, and why
Every check passedA nodeautomaticThe ranking: the node that won, and why
Every check passed, but no node took the podThe Mack8s-start-failed"The Mac named gt-review took it.", then why no node took it

The Kubernetes runner refuses an agent in these cases:

  • The agent's engine is not Claude.
  • The agent is a fix agent.
  • The agent resumes a Claude Code session, whose transcript is on the Mac.
  • The agent forks another session.
  • The agent needs a Mac-only MCP server, such as iMessage, Calendar, Spotify or Mac tools.
  • The agent's checkout has no https://github.com/ or [email protected]: origin.

An agent whose start request names k8s is not moved to the Mac. If the runner refuses it, or no node takes its pod, the start fails with the reason.

A review agent the runtime placed on a node itself gets only the Read, Grep, Glob and LS tools, plus the review tools. Where an agent runs lists the other limits of each runner, and the label a node needs before it takes agents.

How a node is chosen

The runtime ranks the nodes each time it starts a pod.

  1. It reads every node that carries the agent label, and the CPU and memory every pod in the cluster requests.
  2. It skips a node that does not decode, is not Ready, is cordoned, has no internal address, or whose kubelet does not answer on port 10250 within 2 seconds. Each skipped node is named in the reason.
  3. On each remaining node, it cuts the agent's measured size to half of that node's free space. Free space is the node's allocatable CPU and memory, less what its pods already request.
  4. It passes over a node where the cut size is under 1 millicore of CPU or 1 MiB of memory. The reason calls that node full, with what it has free.
  5. It ranks the other nodes, then puts the Mac last.

A node more than 300 ms away ranks after every node that is closer. Exactly 300 ms is not over the limit.

Among the rest, the node left most free wins. The runtime adds the agent's request to the node, then averages the share of CPU and the share of memory left free, in whole percent. The Kubernetes scheduler's least-allocated score counts free space the same way.

On a tie, the shorter round trip wins. On a tie in both, the node whose name sorts first wins.

The pod is pinned to the winner by its kubernetes.io/hostname label, and it asks for the size cut to that node. If Kubernetes marks the pod Unschedulable, the runtime removes it and tries the next node. Any other failure, such as the API, an image pull, the clone or the exec, ends the tries, because it fails the same way on every node. A token file that cannot be read, or a pod that does not render, fails the start as the agent's own failure, and never moves the agent to the Mac.

The round trip

One reading is one TCP connect from the Mac to the node's kubelet port, 10250. A connect costs one round trip, and nothing is sent after it. A connect that has not answered in 2 seconds is no reading.

The runtime takes one reading of each node every 15 seconds. It keeps the newest 20 readings of each node, and drops any reading older than 5 minutes. A node's round trip is the 95th percentile of those readings, so one slow or fast connect does not decide where an agent runs.

With fewer than 3 fresh readings, as just after the runtime starts, the runtime takes the missing readings when the agent starts. A kubelet that does not answer costs one 2-second try, not three.

An example reason

This is the placementDetail of a review agent that PR #2493 started with both nodes empty:

k3s-worker-m5 won: k3s-worker's round trip of 310 ms is over 300 ms, so it ranks after every node under it. The agent asks for 250m CPU and 1024Mi memory. Ranking: 1. k3s-worker-m5 (97% free after it, 114 ms round trip); 2. k3s-worker (98% free after it, 310 ms round trip, over 300 ms); 3. the Mac named gt-review.

The reason names the winner, the rule that put it above the next place, what the agent asks for, and every place in order. When nodes refused the pod or were passed over, it names them too.

How an agent's size is measured

Every 15 seconds the runtime reads the CPU and memory of each live agent.

  • For a pod, it reads the Kubernetes metrics API. A pod's use is the sum of its containers, the credential proxy container included.
  • For a process on the Mac, it reads ps. The runtime marks each agent's process with CONDENSATE_USAGE_KEY, and adds the marked process and every process under it.

When an agent finishes, or the runtime stops, the runtime writes the agent's peak CPU and peak memory to the agent_peaks table in threads.db. Each row records the agent's kind, where it ran, and how many readings it took. A kind is the agent's role, its engine, and whether it has a shell.

A pod's size comes from these rules:

RuleWhat it means
A pod is sized from pod peaks onlyA peak measured on the Mac never sizes a pod. A whole process tree on the Mac, test runs included, is not comparable to a pod.
With 3 or more pod peaks, the kind asks for their 95th percentileThe runtime reads the newest 20 peaks of the kind.
With fewer, the kind asks for its role's starting sizeReview agents start at 250 millicores and 1 GiB. Fix and build agents start at 1 core and 2 GiB. Any other role starts at the review size.
The memory limit is 1.5 times the largest peakA runaway agent is stopped on its own node. There is no CPU limit.
The request is cut to half of the node's free spaceThe pod fits beside what already runs there.

The review starting size comes from a measurement. The two review pods that ran on k3s-worker-m5 on 2026-10-19 peaked at 93 millicores and 675 MiB, so 250 millicores and 1 GiB cover a longer review.

Two limits of the measurement follow from how it works. A 15-second reading can miss a short memory spike, so the memory limit is set from what was seen, not from the true peak. A runtime that is killed, not stopped, loses the peaks of the agents still running.

When the nodes are full

A full node is passed over, and the reason says what it has free. When every node is full, or every node with free space refused the pod, two things happen.

  1. The runtime writes a "too big" row for the agent's kind, at half of what the pod asked for. The next pod of that kind asks for no more than that. A second refusal halves it again, so a kind never stays stuck waiting.
  2. A review agent the runtime placed itself runs on the Mac, with placement set to k8s-start-failed. An agent whose start request named k8s fails.

A pod of that kind that runs and finishes after the "too big" row lifts the limit again.

The heavy-command queue on each node

Placement decides which machine an agent runs on. A heavy-command queue decides when a heavy command inside it runs, such as a test run, a type check or a build. The Mac has one queue, in the runtime. Each Kubernetes node can have its own.

  • On a node, the queue is a Kubernetes DaemonSet named node-queue. It serves the same queue code as the runtime, on the Unix socket /run/condensate/queue/queue.sock.
  • Only the agent user, user 1000, may open the socket. The socket's file mode, 0600, is the access rule, so no network port opens. The daemon's NetworkPolicy admits no traffic.
  • Every agent pod mounts the socket's directory read-only, and gets CONDENSATE_GATE_SOCKET pointing at the socket.
  • The heavy-command wrapper uses the socket when that variable is set, and the runtime on the Mac otherwise. When neither answers, it waits until the machine's 1-minute load is under 45, then runs the command.

A command is charged for the CPU it uses, not only the weight it declares. The wrapper starts each command in a process group of its own. Every 10 seconds, with the default settings, it reports the CPU seconds the group has used. The queue converts two reports into cores, and charges the larger of the declared weight and those cores, rounded.

PR #2498 measured this on k3s-worker-m5. A command that declared weight 1 started ten busy processes, used 9.9 cores, and was charged 10 from its first measured report. A second heavy command, in another pod on the same node, waited 31 seconds until the first one finished.

A node queue's limits come from the node's own 1-minute load and core count. By default it grants nothing at a 1-minute load of 45 or more, and lets one heavy command run at a time. The most weight it grants is 40% of the node's cores, and at least 2. On the 12-core k3s-worker-m5 that is 5.

The runtime does not install the daemon. An operator applies it once per cluster:

bun packages/agents-host/src/gate-daemon.ts manifest --image <agent image> | kubectl apply --server-side -f -

The --server-side flag is needed. A client-side apply copies the daemon's 499 KB script into an annotation, which Kubernetes limits to 256 KB. On a node with no daemon, the pod mounts an empty directory, and the wrapper falls back to the load check.

Check where an agent ran

GET /live lists every live agent. These fields say where each one runs:

FieldWhat it is
runnerprocess, k8s or container. Absent until the agent's engine has started.
nodeThe Kubernetes node the pod runs on. Absent for an agent on the Mac.
placementThe reason, one of the values in the table under "How an agent is placed".
placementDetailThe text that reason carries: the ranking, the refusal, or the nodes the check skipped. Absent when the reason carries none.
curl -s http://127.0.0.1:4741/live | jq '.live[] | {threadId, runner, node, placement, placementDetail}'

The runtime also writes one log line for each agent it starts, with the same facts:

seat_placement key=<thread id> runner=k8s reason=automatic node=k3s-worker-m5 detail="k3s-worker-m5 won: ..."

GET /placement shows each place as the runtime last measured it. It answers from the last reading and starts no command.

FieldWhat it is
places[].placehead for the Mac, or the node's name
places[].capacityCPU in millicores and memory in bytes. For a node, what pods may use. For the Mac, its cores and memory.
places[].usageWhat the agents there use now, or null when it was not read
places[].requestsWhat the pods on a node request, or the measured size of each agent on the Mac
places[].agentsHow many live agents run there
places[].queueThe Mac's heavy-command queue: its load, its capacity, the weight in use and how many commands wait. null for a node, whose queue answers on its own socket.
places[].roundTripMsThe 95th percentile round trip that the ranking uses, in milliseconds
kinds[]Each kind of agent, with its number of peaks and the size its next pod asks for