Skip to main content
This page is a reading tour of one run through the real code. It begins with the definitions at rest, enters Taskset.run, and follows the pointer into rollout, out to the environment server, through tasks.start, into the agent, and back through tasks.grade. Irrelevant branches are trimmed with # .... The primary path has no Task.verifier; the optional verifier branch appears in grading and teardown. On this page: What we start with · Start point: the scheduler · Into the rollout atom · Bringing up the server · Connecting · Starting the task · Checkpoint · Driving the agent · Grading and teardown · The whole chain · Boundaries

What we start with

Before anything runs, we start with an env.py file with the environment declaration and task templates registered on it. An @env.template() decorator turns an async generator into a _TaskFactory and stores it in env.tasks. The generator body is the task: advancing the generator to first yield returns (yields) the prompt, the value sent back is the answer, second yield is the score.
hud/environment/env.py
Calling asend(value) resumes the suspended yield expression with value - that’s the moment answer = yield ... gets populated - then runs the generator forward to the next yield, whose argument becomes asend’s return value. The TaskRunner.start call later in this walkthrough is exactly the __anext__() call above; TaskRunner.grade is exactly the asend(...) call.
hud/environment/env.py · Environment.template
Calling the factory runs nothing. It binds the args and returns a Task row - the row’s env is the environment’s name (a string), not a live object.
hud/environment/env.py · _TaskFactory.__call__
A Taskset is just a named collection of those rows, indexed by slug:

Start point: the scheduler

The pointer enters Taskset.run. It expands the rows, registers one Job, resolves placement once, then fans out one rollout per (task, group) pair with asyncio.gather.
hud/eval/taskset.py · Taskset.run
1 · Expand rows into (task, group) pairs
Each row in the taskset becomes group entries (default 1), and the repeats of one task share a group_id - the tag that later ties their rewards together into one GRPO group.

taskset.py · Taskset.run

2 · Register the Job receipt
A Job is the accumulator this call fills in and returns - every run below lands in job.runs. job_enter registers it with the platform so a running batch shows up before any rollout finishes.

taskset.py · Taskset.run

3 · Resolve placement once, cap concurrency
placement is resolved once into a single callable, not once per row - though that callable can itself branch on the task (see routing patterns). max_concurrent becomes the semaphore’s capacity, capping how many rollouts run at once.

taskset.py · Taskset.run

4 · Fan out one rollout per pair, collect into the job
_one is the unit gather schedules: acquire the semaphore (if there is one), then call rollout - the next hop. gather starts every _one at once, but async with sem: blocks all but max_concurrent of them until a running rollout exits and frees a slot.Each _one returns a one-Run list, so the nested waves gets flattened into individual runs before job.runs.extend appends them.

taskset.py · Taskset.run

Everything below happens inside one rollout(task, agent, runtime=placement, ...).

Into the rollout atom

rollout is the whole lifecycle for one task. The key structure is one AsyncExitStack that stacks three context managers - the provider, the client connection, and the Run - and unwinds them in reverse on the way out.
hud/eval/run.py · rollout
1 · Give the rollout a job and a trace
A rollout called on its own (not through Taskset.run) still needs a Job and a trace id to report against, so it mints its own job_id and trace_id when neither arrives from the caller.

run.py · rollout

2 · Open the AsyncExitStack: provider, then connect
Two of the stack’s three context managers enter here. The provider yields a Runtime address; connect turns that address into a live HudClient. Exiting stack later tears both down, in reverse order.

run.py · rollout · _drive

3 · Enter the Run, drive the agent
The stack’s third context manager is the Run itself: entering it sends tasks.start, exiting it sends tasks.grade (messages to the environment server). Everything the agent does happens between those two lines, inside await agent(run).

run.py · rollout · _drive

4 · Unwind and report
_drive runs the whole nested block above; if it raises, failure isolation still produces a run (failed, or with an errored trace) before trace_exit closes the trace and the graded - or failed - Run returns.

run.py · rollout

The pointer follows stack.enter_async_context in order. First stop: runtime(task).

Bringing up the server

runtime(task) calls whichever provider placement resolved - any callable of the shape (task) -> async context manager yielding a Runtime (see placement). This whole step is the env side of the protocol, the provider and the environment together, which the agent side then connects to. Two pieces make it up: the environment’s serving code, which runs identically inside whatever substrate holds it, and the provider, which brings that substrate up and hands back a tcp:// url reaching its control port.

The serving code

Every substrate runs one entry point, serve() - the python -m hud.environment.server a SubprocessRuntime child runs, and the hud serve a container CMD runs both land here. It starts the env, binds the control channel, prints the bound port, and serves until torn down.
hud/environment/server.py · serve
The initialize hooks run before serving, so every capability is concrete by the time a client connects.
hud/environment/env.py · Environment.start
Then bind puts the env on one TCP port that carries both the control session and raw capability tunnels - the control channel. The bound port is the only thing the provider needs back: child and container substrates announce it on stdout; the in-process provider reads it straight off the socket.

The provider

The default, LocalRuntime, is the smallest provider: it rebuilds the env fresh from its source and serves it in this process, through the _local helper - env.start(), bind on an ephemeral loopback port, and a Runtime pointing at it. Exiting cancels the server and runs env.stop().
hud/eval/runtime.py · _local (entered by LocalRuntime per acquisition)
In-process means the initialize hooks share the caller’s event loop, so blocking env code stalls concurrent rollouts. SubprocessRuntime is the isolation step: it spawns the serve entry point above as a child (python -m hud.environment.server <path> --env name), reads the announced HUD_SERVE_PORT= from its stdout, and terminates the child on exit. Every other provider runs that same serve entry point inside a different substrate and reaches its port a different way: Whichever provider ran, the result is a Runtime(url) whose server is listening. The pointer returns to rollout and enters the second context manager: connect(addr).

Connecting

connect retries the connect-and-hello handshake until the env answers (a freshly bound port can accept before the env behind it is serving), then yields a HudClient with its manifest ready.
hud/clients/client.py · connect
hud/clients/client.py · _connect_ready
hello() sends the frame and parses the reply into a Manifest. It creates a loopback forwarder for every binding and records the routed local URL, so all capability traffic returns through the control address. An optional session_id resumes a parked session instead of minting a fresh one; the suspended task section describes that path.
hud/clients/client.py · HudClient.hello
On the server, the hello branch answers with the env identity and its capabilities (the branch that resumes a requested session_id is elided here; see the link above):
hud/environment/server.py · _ControlChannel.session (hello)
The client holds the manifest. Back in rollout, the pointer builds the Run and enters it.

Starting the task

Run.__aenter__ is the third context manager. It sends tasks.start and stores the prompt the env returns.
hud/eval/run.py · Run.__aenter__
hud/clients/client.py · HudClient.start_task
The frame reaches the server’s tasks.start branch, which creates a TaskRunner and starts it, holding it on the channel under this connection’s session id:
hud/environment/server.py · session (tasks.start) + _ControlChannel.start
TaskRunner.start instantiates the async generator and runs it to the first yield - the prompt:
hud/environment/server.py · TaskRunner.start
The prompt travels back: TaskRunner.start -> server reply -> client.start_task -> Run.prompt.

Checkpoint

The pointer is inside async with live:, just before await agent(run). At this moment run is a Run that holds:
  • an attached client (run.client), which already has the manifest and whose server has a TaskRunner suspended at the task’s first yield,
  • the prompt the env returned, on run.prompt,
  • a run.trace containing the task setup step and, when a prompt was returned, its opening user step.

Driving the agent

rollout hands the run to the agent. The agent contract is one method: async __call__(run).
hud/eval/run.py · rollout (agent loop)
hud/agents/base.py · Agent
Inside, the agent reads the manifest through the run’s client and opens the capabilities it needs, then loops - acting, observing, recording steps - until it has an answer. Everything it does lands on run.trace; the final answer is run.trace.content.
When __call__ returns, the pointer leaves the async with live: block, which triggers Run.__aexit__.

Grading and teardown

Run.__aexit__ sends tasks.grade with the answer taken from trace.content, and parses the reply into a Grade (the env’s score becomes reward). On a Ctrl-C it cancels instead of grading.
hud/eval/run.py · Run.__aexit__
The server’s tasks.grade branch pops this session’s held runner and resumes it (or, with no runner of its own, adopts the lone parked one - see the suspended task):
hud/environment/server.py · session (tasks.grade) + _ControlChannel.grade
TaskRunner.grade sends the answer into the paused generator (optionally wrapped as Answer[T] when returns= was declared), which advances past the first yield, evaluates, and yields the score:
hud/environment/server.py · TaskRunner.grade
The score travels back: TaskRunner.grade -> server reply -> client.grade -> Grade.from_dict -> run.grade.reward. When task.verifier is present, the actor grade is provisional. A verifier in the same environment with no row-level runtime configuration starts on the existing client. Otherwise the actor client and provider exit before the same provider is called with the verifier row. _verify starts that task without an agent loop and immediately grades it with run.trace.content; its evaluation replaces the actor grade. The full phase contract is documented in verifier environments. The AsyncExitStack unwinds in reverse: connect closes the client (forwarders and socket), then the provider context exits and tears the substrate down - for LocalRuntime it cancels the in-process server and runs env.stop() (@env.shutdown); for SubprocessRuntime it terminates the child, whose serve does the same on the way out. Back in rollout, trace_exit(run) reports the trace and the graded Run returns to Taskset.run, which collects it into job.runs.

The whole chain

Every hop above, in order:
  1. Taskset.run - expand rows, register a Job, resolve placement, gather one rollout per (task, group).
  2. rollout - open an AsyncExitStack: provider, then connect, then Run.
  3. runtime(task) - bring the env up in its substrate: LocalRuntime serves it in-process via _local; SubprocessRuntime spawns the serve entry point and reads its announced port.
  4. serve (in the substrate) - env.start() (initialize hooks), bind() a TCP server, serve_forever(); the provider yields a Runtime(url).
  5. connect(addr) - retry until ready, HudClient.hello() -> server returns the manifest.
  6. Run.__aenter__ - client.start_task -> tasks.start -> TaskRunner.start runs the generator to the first yield -> prompt back on run.prompt.
  7. Checkpoint - run holds a live client (manifest + suspended runner) and the prompt.
  8. await agent(run) - agent opens capabilities via run.client, loops, fills run.trace (answer on trace.content).
  9. Run.__aexit__ - client.grade -> tasks.grade -> TaskRunner.grade resumes the generator to the second yield -> provisional score -> run.grade.reward.
  10. Optional verifier - reuse the live substrate or finish actor cleanup and acquire the verifier substrate; start and grade the verifier with trace.content; replace the actor grade.
  11. Unwind - close the active client, stop the substrate (serve runs env.stop()), trace_exit, return the graded Run to Taskset.run.

Boundaries

This reading path exposes the boundaries encoded by the rollout engine.
  • Each provider acquisition yields one control-channel address. The address can represent a process, one container, or a Compose project whose main service owns the channel. A verifier in another environment causes a second acquisition after the actor acquisition exits.
  • A control channel holds one suspended task per session. A _ControlChannel keys its suspended TaskRunners by session id, so concurrent sessions each own their own, and a dropped connection parks its session’s task for a later one to grade (see the control channel). Vectorized robot sims reuse that shape: N sessions on one control port, each claiming a bridge slot by token.
  • The agent loop runs in the caller’s process. Every provider except HostedRuntime is client-here: the substrate can be anywhere, but agent(run) executes locally. With the default LocalRuntime the env serves in this same process too, so its hooks share the caller’s event loop and blocking env code stalls concurrent rollouts; SubprocessRuntime and DockerRuntime move the env to its own substrate, where hooks run isolated from the caller.
  • Every capability is concrete by hello time. env.start() runs all initialize hooks before serving, and the manifest is negotiated once. A capability published later will not appear in an already-connected client.
  • A template generator is single-use per start. grade closes it after the second yield, so one TaskRunner grades exactly once; re-running the task means a fresh tasks.start.
  • Placement is chosen once per batch. Taskset.run resolves one provider for all rows. Per-row heterogeneity is possible only because the Provider contract takes the task and can branch on it - the engine never does.