Five small modules in rl_engine/rollout/scheduler/: they batch sample groups by task_type, gate each batch on a PlacementPool, and run it in a short-lived Evaluator built from an EvaluatorSpec. Driven by SchedulerRolloutManager, feeding the inference backend, gating on ROCK sandbox capacity.

1 Role in the Rollout Pipeline

The TaskScheduler (task_scheduler.py:127) drives a rollout step. It is not a Ray actor — it runs as plain async Python inside the SchedulerRolloutManager actor; only the SGLang engine holds a persistent Ray allocation.

Every step makes the same four moves — fetch → distribute → batch → run — traced below, with results streaming back through on_task_done as each task completes.

task_type = swe task_type = retool Data Source MultiDataSource .get_samples(n) distribute() → Task _pending_tasks Task · swe Task · retool Task · swe Task · retool fifo_batching() group by task_type Batch · swe seals at batch_size Batch · retool seals at batch_size encounter order kept (FIFO) batch_size=None ⇒ one batch per type run_all() scan FIFO · gate on pool try_acquire_batch() create_evaluator() wait(FIRST_COMPLETED) release_batch() idle ⇒ force-acquire 1 token (progress guarantee) Evaluators short-lived · one per batch Evaluator[swe] handler: init → run → eval → cleanup Semaphore(concurrency) 1 token / sample Evaluator[retool] handler: init → run → eval → cleanup Semaphore(concurrency) 1 token / sample on_task_done → columnar train data

One execute_rollout() cycle: the mixed _pending_tasks queue is grouped by task_type, each batch gated on the PlacementPool and run by a short-lived Evaluator. Ungated batches stay pending and re-scan when a running batch releases; an idle scheduler force-acquires one token so a rollout never stalls.

Concurrency model

Two levels of concurrency. Across batches, run_all() awaits an asyncio.Task per batch with asyncio.wait(return_when=FIRST_COMPLETED), re-scanning as tokens free. Within a batch, samples run via asyncio.gather under an asyncio.Semaphore(resource.concurrency). Total in-flight work is capped by the PlacementPool, not the event loop.

2 Batching Strategies

Batch composition is the scheduler's one pluggable decision. A BatchingStrategy (task_scheduler.py:57) is any callable with the signature below; everything downstream is uniform.

# task_scheduler.py:57 — the strategy is a plain callable, no class hierarchy
BatchingStrategy = Callable[[list[Task], dict[str, EvaluatorSpec]], list[Batch]]

TaskScheduler.__init__ takes an optional batching_strategy, defaulting to fifo_batching (task_scheduler.py:156) — the only shipped strategy.

fifo_batching default

It groups tasks by task_type in encounter order, sealing a batch at the matching spec's batch_size (task_scheduler.py:60):

def fifo_batching(tasks, specs) -> list[Batch]:
    batches, open_batch = [], {}
    for task in tasks:
        ttype = task.config.task_type
        batch_size = specs[ttype].batch_size if ttype in specs else None
        if ttype not in open_batch:                 # start a batch for this type
            b = Batch(task_type=ttype); open_batch[ttype] = b; batches.append(b)
        open_batch[ttype].tasks.append(task)
        if batch_size is not None and len(open_batch[ttype].tasks) >= batch_size:
            del open_batch[ttype]                        # seal → next task opens a new one
    return batches

Batching strategy

A homogeneous batch keeps each Evaluator on one handler and inference path. MultiDataSource serves datasets sequentially, so same-type tasks cluster; with batch_size=None, all tasks of a type collapse into one batch — the original single-evaluator behavior.

Task-type-aware interleaving on the eval path

On the eval path, execute_eval() builds every (prompt × copy) sample and interleaves them round-robin — ABCABC… not AABBCC… (task_scheduler.py:746-774) — so concurrent tasks span datasets and smooth sandbox/inference pressure. All share one run_all(), split apart afterward by _eval_dataset_name.

Spec resolution & the config cascade

Each batch resolves its spec via _resolve_spec(): exact evaluator_id match, else a warning and fallback to the first spec (task_scheduler.py:321). Two generation settings resolve through a three-level cascade:

SettingLevel 1 (coarsest)Level 2Level 3 (finest)
StoppingCriteriahardcoded max_turns=16EvaluatorSpec.default_stoppingper-dataset max_turns / max_context_len
sampling_paramsCLI rollout_* argsper-dataset temperature / top_p / top_k / max_response_len

_resolve_stopping — task_scheduler.py:162 · _build_sampling_params — task_scheduler.py:200. The per-dataset overrides ride in on each sample's metadata["_dataset_cfg"], set by MultiDataSource.

3 The Two-Level Resource Pool

PlacementPool (resource.py:137) is admission control in two composing modes: a global pool from the cluster layout gates whole batches; a short-lived local pool hands one token per running sample.

Global PlacementPool built from --env-cluster-layout JSON → NodeSpec list node 127.0.0.1 GPU 0 · cap 1.0 GPU 1 · cap 1.0 GPU 2 · cap 1.0 GPU 3 · cap 1.0 cpu 96 cores · memory 384 GB — tracked per node max_tokens_for(per_sample) cpu 96 / 4 = 24 mem 384 / 16 = 24 → capacity = 24 tokens atomic · all-or-nothing · rollback try_acquire_batch() → list[PlacementToken] Evaluator[swe] · local pool PlacementPool(tokens=[…]) — a token queue token token token token acquire() run / eval (container) release() one token held across run+eval; released in a finally block Evaluator[retool] · local pool PlacementPool(tokens=[…]) — a token queue token token token token acquire() run / eval (container) release() return to this queue, not the global pool, until batch end

Two-level allocation. The global pool tracks each node's GPUs (capacity 1.0 apiece), CPU, and memory. try_acquire_batch() carves a batch's tokens atomically; those seed a local PlacementPool in the evaluator, which lends one per sample across its run+eval and reclaims it on completion. release_batch() returns everything to the global pool at batch end.

Resource descriptors

Three dataclasses describe supply and demand (resource.py:18-90):

TypeFieldsRole
Resourcegpu: float, cpu: float, memory_gb: float, concurrency: int=64, custom: dictA budget or a demand. satisfies() / allocate() / add() do the arithmetic. gpu is a float, so fractional GPU sharing is supported.
NodeSpecnode_ip, gpu_ids: list[int], cpu, memory_gbOne physical node. Each listed GPU starts at capacity 1.0.
PlacementTokennode_ip, gpu_id, gpu, cpu, memory_gb (frozen)An allocated slot on a specific node + GPU. Carries exactly what it reserved so release() can restore it.

All-or-nothing batch acquisition

try_acquire_batch(budget, per_sample, max_tokens) (resource.py:220) takes tokens one per_sample at a time — under the pool lock, greedily from the node with the most free GPU, cross-node allowed — until budget is spent or max_tokens (the sample count) is hit. If any token can't be placed, all roll back and it returns None, so the batch waits rather than half-commit resources that block others.

Fractional GPU sharing

Each GPU has capacity 1.0; a sample consumes its Resource.gpu fraction, so gpu=0.33 packs three jobs per GPU (__init__.py:48-51). _NodePool.try_allocate best-fits by remaining capacity (resource.py:104); max_tokens_for counts floor(1.0 / per_sample.gpu) slots per GPU. The SWE recipe sets gpu=0 — sandboxes are CPU/memory-bound and student/teacher GPUs belong to SGLang — so SWE batches gate on CPU and memory alone.

Deadlock prevention

Before launching, run_all() asserts the pool can meet the largest per-spec demand it will ever request (task_scheduler.py:358-387): the sample_resource tokens that fit a spec's resource budget (capped by the biggest batch) must not exceed max_tokens_for(sample_resource):

tokens_needed = min(budget_tokens, max_batch_samples[spec.evaluator_id])
pool_capacity = pool.max_tokens_for(spec.sample_resource)
assert pool_capacity >= tokens_needed, (
    f"Deadlock: EvaluatorSpec '{spec.evaluator_id}' needs {tokens_needed} "
    f"placement tokens but the pool can only provide {pool_capacity}…")

It fails fast at startup instead of hanging on an unadmittable batch.

Cluster layout schema

_build_placement_pool (__init__.py:37) reads --env-cluster-layout as inline JSON or a file path. Each node is a GPU-id list (shorthand) or a full dict; missing cpu/memory_gb fall back to --env-num-cpus / --env-memory-gb. Absent the flag, the pool is None and batches launch ungated. SWE scripts generate it per run:

# rl_engine/examples/swe_agent/config/cluster_layout.json — full-dict form
{
  "127.0.0.1": { "gpus": [0, 1, 2, 3], "cpu": 96, "memory_gb": 384 }
}

# shorthand — a bare GPU-id list; cpu/mem come from --env-num-cpus / --env-memory-gb
{ "127.0.0.1": [0, 1, 2, 3] }

4 Evaluator Lifecycle

An Evaluator (evaluator.py:197) is created for one batch, drains its queue, and is discarded. It holds no domain logic — a BaseAgent handler owns the episode; its EvaluatorSpec binds that handler to a resource model:

# evaluator_config_dagger.py:80 — the SWE-DAgger spec (real values)
EvaluatorSpec(
    evaluator_id="swe",
    handler=DAggerSWEAgent(…),
    resource=Resource(gpu=0, concurrency=env_concurrency, cpu=96, memory_gb=320),  # per-evaluator budget
    run_resource=Resource(gpu=0, concurrency=1, cpu=4, memory_gb=16),   # per-sample container
    eval_resource=Resource(gpu=0, concurrency=1, cpu=4, memory_gb=16),  # per-sample verifier
    batch_size=args.evaluator_batch_size or 1,
    default_stopping=StoppingCriteria(max_turns=100, max_context_len=65536),
)

Since run and eval never overlap for a sample, the spec's sample_resource (evaluator.py:68) is the per-field max(run_resource, eval_resource) — the peak one sample holds, pre-acquired from the global pool. Per-evaluator concurrency is Resource.concurrency: the SWE recipe sets it from STACX_DAGGER_ENV_CONCURRENCY (default 4), the installed-scaffold path from --evaluator-concurrency (evaluator_factory.py:225). Either becomes the asyncio.Semaphore ceiling.

Per-sample execution

_execute_sample (evaluator.py:338) is the core. Under the semaphore it acquires one token and holds it across the whole episode — never releasing between phases — so at most concurrency containers exist at once:

init

handler.init(sample, context). Prepares the episode; the token is already held.

run

handler.run(...) → AgentResult. The multi-turn loop against the sandbox; result.output_text becomes sample.response before eval.

eval

handler.eval(...) → reward. For installed scaffolds the verifier transport is _execute_verifier (agents/installed.py:498); the in-house SWE agent scores in its own eval().

cleanup

handler.cleanup(...) in a finally, then the token is released — unconditionally, even on CancelledError, to prevent pool starvation.

After eval, _populate_token_data copies token IDs, log-probs, and the loss mask off the AgentResult onto the sample for the trainer. Samples in a group run via asyncio.gather; Evaluator.run() fires on_task_done per task so tqdm and the collector update live.

Abort classification

When a sample raises, the except block marks it ABORTED, sets reward={"score": 0.0}, and injects a 2-token dummy with remove_sample=True — zero gradient, but Megatron's batch shape stays intact (evaluator.py:429-452). It preserves the agent's own termination reason before overwriting it with a transport classification:

_agent_reason = sample.metadata.get("_termination_reason")
if _agent_reason is not None:
    sample.metadata["_termination_reason_agent"] = _agent_reason   # kept for diagnostics
sample.metadata["_termination_reason"] = classify_abort(str(e))   # agent_timeout / infra_*

classify_abort (termination_summary.py:143) maps the exception to agent_timeout / infra_conn / infra_sandbox / infra_other; classify_termination folds (reward, reason) into one of eight failure classes below. _annotate_abort attaches diagnostics (phase, window, node IP, turns) for the summary.

Failure classTrigger
solvedreward == 1.0
finish_unresolvedagent called finish but the answer was wrong
loop / ctx_overflow / budgetrepeated_action_loop / context_overflow / action_budget or max_turns
timeout / infraabort classified as agent_timeout / infra_*
otherany unrecognized termination reason

5 Training-Loop Integration

The scheduler alone knows the current rollout index, so it publishes per-iteration state as env vars the agent and loss code read — set by execute_rollout() before the fetch loop (task_scheduler.py:481-497):

ExportValueWhen
STACX_ROLLOUT_IDtrain_{rollout_id} (rollout) · the eval label (eval)every step — logging + snapshot naming
STACX_DAGGER_BETA_dagger_schedule.beta(rollout_id)when a DAggerSchedule is attached; its presence also marks a training (vs. eval) rollout
STACX_DAGGER_TAIL_T_dagger_schedule.t_max(rollout_id)only when t_max_step > 0 — the AggreVaTe κ-schedule is active

The t_max_step > 0 guard avoids clobbering a statically-set tail length with T_max=0. execute_eval() pops STACX_DAGGER_BETA, so eval runs student-only at β=0 (re-injectable via STACX_DAGGER_EVAL_BETA). The schedule lives in DAggerSchedule (rollout/utils/dagger_schedule.py) — linear/cosine/exponential/step β decay plus a linear t_max ramp — built at train.py:281. See Algorithm Recipes.

Where filtering happens. The scheduler returns raw trajectories; rejection sampling, the finish-only filter, reuse, and chunk expansion all run one level up in _get_rollout_data (rollout_manager.py:187), after execute_rollout() returns.

Rejection sampling & the finish-only filter

Two independent survivorship filters compose — rejection first, finish-only on the survivors (rollout_manager.py:446-467).

Env varModesEffect
STACX_REJECTION_SAMPLING
rollout_manager.py:122
1/all, 0/none/"", warmup_onlyKeep only trajectories with reward.score > 0. warmup_only applies the filter only while rollout_id < warmup_iters — high-quality data during the warm SFT prefix, but learn from failures once the student starts executing turns.
STACX_FINISH_ONLY_FILTER
rollout_manager.py:159
1/all, 0/none/""Keep only trajectories whose _termination_reason == "finish". Code default is off; the DAgger/OPD launch scripts set 1. Drops context_overflow, action_budget, max_turns, loops, aborts.

Reuse & resume

Two resume paths coexist, both keyed off the saved trajectory .pt (rollout_manager.py:201-243):

  • Fresh (live DAgger / AggreVaTe / OPD): STACX_FORCE_FRESH_ROLLOUT=1 bypasses reuse; every iter rolls out with the improving student, saving trajectory_sft_samples.pt plus a per-iter …_iter_NNN.pt (gated by STACX_SAVE_PER_ITER_PT, default on).
  • Reuse (iter-SFT): STACX_REUSE_ROLLOUT=1 always loads the .pt; STACX_REUSE_PER_ITER_DIR replays a directory of per-iter dumps in sequence. Without either, rollout_id > 0 auto-reuses iter-0's data (multi-epoch SFT).

execute_rollout() also writes a write-only partial checkpoint per group (task_scheduler.py:556-560); it is never loaded on resume — a resumed rollout re-runs from scratch, since the data source's cursor and RNG aren't checkpointed.

Eval outputs

With --save-eval-details, execute_eval() streams three artifacts per iteration: a per-sample JSONL (eval_details/{id}.jsonl), a per-turn placement log (node IP + GPU), and a failure-class summary.json. Rollout steps emit the same at eval_details/train_{id}.summary.json. Per-dataset metrics are eval/{name}/accuracy and, when n_samples_per_eval_prompt > 1, eval/{name}/pass@k.

6 Knob Reference

Flags and variables on the scheduler path. CLI flags are set once at launch; code-set STACX_* variables are written per iteration — never set those by hand. Full catalog in scripts/train/swe/ENV_REFERENCE.md.

Flag / variableRead atDefaultEffect
--env-cluster-layout__init__.py:52NoneNode → GPU/CPU/mem layout for the global pool; None ⇒ no gating.
--env-num-cpus / --env-memory-gb__init__.py:680 / 0.0Fallback CPU/mem for shorthand layout entries.
--evaluator-batch-sizetrain.py:1269None→1Max tasks per batch; None ⇒ all tasks of a type in one batch.
--evaluator-concurrencytrain.py:1270NonePer-evaluator concurrency for installed scaffolds (→ Resource.concurrency).
--rollout-ckpt-dirtask_scheduler.py:512NoneDirectory for the write-only partial rollout checkpoint.
STACX_DAGGER_ENV_CONCURRENCYevaluator_config_dagger.py:714In-house SWE evaluator concurrency (semaphore ceiling).
STACX_REJECTION_SAMPLINGrollout_manager.py:1220/noneReward>0 survivorship filter (1/all/warmup_only).
STACX_FINISH_ONLY_FILTERrollout_manager.py:1590 (scripts 1)Keep only _termination_reason=="finish".
STACX_FORCE_FRESH_ROLLOUTrollout_manager.py:2360 (scripts 1)Bypass .pt reuse — fresh rollout every iter.
STACX_REUSE_ROLLOUT / STACX_REUSE_PER_ITER_DIRrollout_manager.py:229,2180 / ""Always load saved .pt / replay a per-iter dump directory.
STACX_SAVE_PER_ITER_PTrollout_manager.py:4231Also save a per-iter trajectory snapshot for forensics/resume.
STACX_SHUFFLE_SEED_BASErollout_manager.py:301,49042Base seed for the per-iter deterministic chunk shuffle.
STACX_KL_DUMP_DIRtask_scheduler.py:908""Per-iter student-trajectory dump dir (offline analysis); empty ⇒ off.

7 Class & Function Reference

SymbolLocationDescription
TaskSchedulerscheduler/task_scheduler.py:127Batch scheduler — registry, pending queue, strategy, pool; execute_rollout/execute_eval/run_all.
fifo_batchingscheduler/task_scheduler.py:60Default strategy — group by task_type in encounter order, seal at batch_size.
BatchingStrategyscheduler/task_scheduler.py:57Callable[[list[Task], dict[str,EvaluatorSpec]], list[Batch]] type alias.
Batchscheduler/task_scheduler.py:43Same-type task group destined for one evaluator.
EvaluatorSpecscheduler/evaluator.py:33Blueprint: handler + resource + run/eval_resource + batch_size + default_stopping.
Evaluatorscheduler/evaluator.py:197Ephemeral per-batch executor; runs init→run→eval→cleanup per sample under a semaphore.
Resource / NodeSpec / PlacementTokenscheduler/resource.py:18/71/81Budget-or-demand descriptor / node spec / allocated slot.
PlacementPoolscheduler/resource.py:137Two-mode pool: global batch gating + local per-step tokens; try_acquire_batch, max_tokens_for.
Task / TaskConfig / StoppingCriteria / TaskStatusscheduler/task.pyUnit of work + generation config + loop-termination conditions + lifecycle enum.
build_task_scheduler(args)scheduler/__init__.py:97Factory — loads specs from --evaluator-config-path, builds the global pool, returns a TaskScheduler.
classify_abort / classify_terminationrollout/termination_summary.py:143/78Exception → termination reason; (reward, reason) → failure class.

Related pages: Rollout Architecture · Inference Backend · Sandbox Management · Algorithm Recipes.