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 anenv.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
Advancing fix_bug by hand
Advancing fix_bug by hand
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
Task row - the row’s env is
the environment’s name (a string), not a live object.
hud/environment/env.py · _TaskFactory.__call__
Taskset is just a named collection of those rows, indexed by slug:
Start point: the scheduler
The pointer entersTaskset.run. It expands the rows, registers one Job, resolves placement
once, then fans out one rollout per (task, group) pair with asyncio.gather.
Full code
Full code
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
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.
Full code
Full code
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
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
hud/environment/env.py · Environment.start
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)
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
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)
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
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
TaskRunner.start -> server reply -> client.start_task ->
Run.prompt.
Checkpoint
The pointer is insideasync with live:, just before await agent(run). At this moment
run is a Run that holds:
- an attached
client(run.client), which already has themanifestand whose server has aTaskRunnersuspended at the task’s first yield, - the
promptthe env returned, onrun.prompt, - a
run.tracecontaining 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
run.trace; the final answer is run.trace.content.
__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__
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
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:Taskset.run- expand rows, register aJob, resolve placement,gatheronerolloutper (task, group).rollout- open anAsyncExitStack: provider, then connect, thenRun.runtime(task)- bring the env up in its substrate:LocalRuntimeserves it in-process via_local;SubprocessRuntimespawns the serve entry point and reads its announced port.serve(in the substrate) -env.start()(initialize hooks),bind()a TCP server,serve_forever(); the provider yields aRuntime(url).connect(addr)- retry until ready,HudClient.hello()-> server returns themanifest.Run.__aenter__-client.start_task->tasks.start->TaskRunner.startruns the generator to the first yield ->promptback onrun.prompt.- Checkpoint -
runholds a live client (manifest + suspended runner) and the prompt. await agent(run)- agent opens capabilities viarun.client, loops, fillsrun.trace(answer ontrace.content).Run.__aexit__-client.grade->tasks.grade->TaskRunner.graderesumes the generator to the second yield -> provisionalscore->run.grade.reward.- 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. - Unwind - close the active client, stop the substrate (
serverunsenv.stop()),trace_exit, return the gradedRuntoTaskset.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
mainservice 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
_ControlChannelkeys its suspendedTaskRunners 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
HostedRuntimeis client-here: the substrate can be anywhere, butagent(run)executes locally. With the defaultLocalRuntimethe env serves in this same process too, so its hooks share the caller’s event loop and blocking env code stalls concurrent rollouts;SubprocessRuntimeandDockerRuntimemove the env to its own substrate, where hooks run isolated from the caller. - Every capability is concrete by
hellotime.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.
gradecloses it after the second yield, so oneTaskRunnergrades exactly once; re-running the task means a freshtasks.start. - Placement is chosen once per batch.
Taskset.runresolves one provider for all rows. Per-row heterogeneity is possible only because theProvidercontract takes thetaskand can branch on it - the engine never does.