# Where agents run (https://condensate.dev/concepts/placement/)

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

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.

```text
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

| Place | What it is | Cores and memory for agents | Round trip from the Mac, as a 95th percentile |
| --- | --- | --- | --- |
| `head` | The Mac named gt-review. Agents run on it as local processes. | 10 cores, 64 GiB | 0 ms |
| `k3s-worker-m5` | A Kubernetes node on the M5. | 12 cores, 31.4 GiB | 12 ms to 114 ms |
| `k3s-worker` | A Kubernetes node in a VM on the M3 Max. | 14 cores, 31.4 GiB | 257 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.

| Check | Where the agent runs | `placement` | What `placementDetail` holds |
| --- | --- | --- | --- |
| The start request names a runner: `process`, `k8s` or `container` | That runner | `hint` | The ranking, when the runner is `k8s` |
| The agent is not a review agent | The Mac | `role` | Nothing |
| `CONDENSATE_KUBECONFIG` is not set | The Mac | `not-configured` | Nothing |
| The Kubernetes runner would refuse the agent | The Mac | `refused` | The refusal |
| No branch on origin holds the checkout's current commit, so a pod cannot clone it | The Mac | `head-not-on-origin` | Nothing |
| The check of the nodes found no node to run on | The Mac | `probe-failed` | Each node it skipped, and why |
| Every check passed | A node | `automatic` | The ranking: the node that won, and why |
| Every check passed, but no node took the pod | The Mac | `k8s-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 `git@github.com:` 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](https://condensate.dev/guides/workspaces/) 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:

| Rule | What it means |
| --- | --- |
| A pod is sized from pod peaks only | A 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 percentile | The runtime reads the newest 20 peaks of the kind. |
| With fewer, the kind asks for its role's starting size | Review 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 peak | A runaway agent is stopped on its own node. There is no CPU limit. |
| The request is cut to half of the node's free space | The 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:

```sh
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:

| Field | What it is |
| --- | --- |
| `runner` | `process`, `k8s` or `container`. Absent until the agent's engine has started. |
| `node` | The Kubernetes node the pod runs on. Absent for an agent on the Mac. |
| `placement` | The reason, one of the values in the table under "How an agent is placed". |
| `placementDetail` | The text that reason carries: the ranking, the refusal, or the nodes the check skipped. Absent when the reason carries none. |

```sh
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:

```text
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.

| Field | What it is |
| --- | --- |
| `places[].place` | `head` for the Mac, or the node's name |
| `places[].capacity` | CPU in millicores and memory in bytes. For a node, what pods may use. For the Mac, its cores and memory. |
| `places[].usage` | What the agents there use now, or `null` when it was not read |
| `places[].requests` | What the pods on a node request, or the measured size of each agent on the Mac |
| `places[].agents` | How many live agents run there |
| `places[].queue` | The 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[].roundTripMs` | The 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 |

## Implementation

The machines

- The Mac's place name is `HEAD_PLACE` in `packages/agents-host/src/usage-sampler.ts:15`. Its capacity is its cores and memory, read in `packages/agents-host/src/placement.ts:56`.
- `k3s-server` lost its agent label on 2026-10-17, and `k3s-worker` is a VM on the M3 Max (`docs/architecture/seats-and-cluster.md:190`).
- The cores, memory and round trips are the `GET /placement` answers and the connect readings in the bodies of PRs #2488 and #2493.
- PR #2482 measured the cost of pod hooks on `k3s-worker-m5`. A `kubectl exec` round trip took 120.8 ms at the median and 249.1 ms at the 95th percentile. A middleware post took 1.4 ms at the median.
- A Claude Code pod agent gets the middleware, because its hooks run in the runtime on the Mac (`packages/agents-host/src/sessions.ts:1794`). Only the CLI's stream crosses `kubectl exec` (`packages/agents-host/src/k8s-runner.ts:114`). The container runner gets no middleware.

How an agent is placed

- The reasons are `PlacementReason` in `packages/agents-host/src/runner-policy.ts:91`. `defaultRunner` checks them in `packages/agents-host/src/runner-policy.ts:127`.
- `resolveAutomaticRunner` reads the nodes only for an agent that passed the earlier checks (`packages/agents-host/src/runner-policy.ts:149`).
- The Kubernetes refusals are `runnerRefusal` and `k8sRunnerRefusal` in `packages/agents-host/src/runner-policy.ts:26` and `packages/agents-host/src/runner-policy.ts:70`.
- The fall-back to the Mac and its sentence are in `sessionSpawner` (`packages/agents-host/src/runner.ts:92`), where `CLUSTER_UNAVAILABLE` is caught (`packages/agents-host/src/runner.ts:105`).
- The `k8s-start-failed` reason is set where a placed pod started as a process (`packages/agents-host/src/sessions.ts:1908`).
- The read-only tools are `POD_READ_TOOLS` in `packages/agents-host/src/runner-policy.ts:46`.

How a node is chosen

- The node check is `placeOf` and `seatPlacesFrom` in `packages/agents-host/src/runner-readiness.ts:33` and `packages/agents-host/src/runner-readiness.ts:57`. Its two `kubectl` reads are in `packages/agents-host/src/runner-readiness.ts:76`.
- The ranking is `rankPlaces` in `packages/agents-host/src/place-rank.ts:72`. The 300 ms limit is `SLOW_ROUND_TRIP_MS` in `packages/agents-host/src/place-rank.ts:9`, and the free share is `freePercentAfter` in `packages/agents-host/src/place-rank.ts:64`.
- The reason text is `nodeReason` and `noNodeReason` in `packages/agents-host/src/place-rank.ts:111` and `packages/agents-host/src/place-rank.ts:119`.
- The node pin is in `packages/agents-host/src/k8s-render.ts:152`. The tries in rank order are in `packages/agents-host/src/k8s-runner.ts:148`.
- One reading is `tcpRoundTrip` in `packages/agents-host/src/round-trips.ts:32`, and the readings of each node are kept by `makeRoundTrips` in `packages/agents-host/src/round-trips.ts:48`.
- The port is `KUBELET_PORT` in `packages/agents-host/src/round-trips.ts:10`. The 2-second limit, the 20-reading window, the 5-minute age and the 3-reading minimum are `ROUND_TRIP_TIMEOUT_MS`, `ROUND_TRIP_WINDOW`, `ROUND_TRIP_MAX_AGE_MS` and `ROUND_TRIP_MIN_READINGS` in `packages/agents-host/src/round-trips.ts:12`.

How an agent's size is measured

- The 15-second reading is `SAMPLE_EVERY_SECONDS` in `packages/agents-host/src/usage-sampler.ts:13`. A peak is written when an agent leaves (`packages/agents-host/src/usage-sampler.ts:139`) and when the runtime stops (`packages/agents-host/src/usage-sampler.ts:224`).
- The process marker is `USAGE_KEY_ENV` in `packages/agents-host/src/agent-usage.ts:8`, set in `packages/agents-host/src/runner.ts:77`. A kind is `agentKindOf` in `packages/agents-host/src/agent-usage.ts:56`.
- The 20-peak window is `PEAK_WINDOW` in `packages/agents-host/src/agent-peaks.ts:12`.
- The starting sizes are `ROLE_FLOORS` in `packages/agents-host/src/agent-peaks.ts:27`.
- The size for a kind is `sizeFor` in `packages/agents-host/src/agent-peaks.ts:80`.
- The cut to half the free space is `capToNode` in `packages/agents-host/src/agent-peaks.ts:113`.
- PR #2488 names the two limits of the measurement in its notes.

When the nodes are full

- The "too big" row is `tooBigCeiling` in `packages/agents-host/src/agent-peaks.ts:98`, written in `packages/agents-host/src/k8s-runner.ts:166`.
- A later real peak lifts it (`packages/agents-host/src/agent-peaks.ts:191`).

The heavy-command queue on each node

- The socket, its mode and its variable are in `packages/agents-host/src/gate-node.ts:12`. The DaemonSet and its NetworkPolicy are `renderGateDaemon` in `packages/agents-host/src/gate-node.ts:58`.
- The pod's mount and variable are in `packages/agents-host/src/k8s-render.ts:144`.
- The daemon reads its limits with `gateSettingsFrom` (`packages/agents-host/src/gate-daemon.ts:17`). Its command line is in `packages/agents-host/src/gate-daemon.ts:13`.
- The charge rule is `chargeFor` in the runtime's queue module, and the measured proof is in PR #2498. The queue module and the wrapper script are listed in this page's sources. The wrapper's comment at its top describes the socket choice and the load check.

Check where an agent ran

- The fields are `LiveSessionView` in `packages/agents-host/src/sessions.ts:1151`, filled by `liveSessions` in `packages/agents-host/src/sessions.ts:2632`. `GET /live` answers them in `packages/agents-host/src/http.ts:2338`.
- The log line is written in `packages/agents-host/src/sessions.ts:1917`.
- `GET /placement` is `placementView` in `packages/agents-host/src/placement.ts:68`.
