Task Scheduler
Batch composition, two-level resource gating, and the per-iteration training hooks that turn a stream of prompts into scored trajectories.
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.
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:
| Setting | Level 1 (coarsest) | Level 2 | Level 3 (finest) |
|---|---|---|---|
StoppingCriteria | hardcoded max_turns=16 | EvaluatorSpec.default_stopping | per-dataset max_turns / max_context_len |
sampling_params | CLI rollout_* args | — | per-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.
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):
| Type | Fields | Role |
|---|---|---|
Resource | gpu: float, cpu: float, memory_gb: float, concurrency: int=64, custom: dict | A budget or a demand. satisfies() / allocate() / add() do the arithmetic. gpu is a float, so fractional GPU sharing is supported. |
NodeSpec | node_ip, gpu_ids: list[int], cpu, memory_gb | One physical node. Each listed GPU starts at capacity 1.0. |
PlacementToken | node_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 class | Trigger |
|---|---|
solved | reward == 1.0 |
finish_unresolved | agent called finish but the answer was wrong |
loop / ctx_overflow / budget | repeated_action_loop / context_overflow / action_budget or max_turns |
timeout / infra | abort classified as agent_timeout / infra_* |
other | any 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):
| Export | Value | When |
|---|---|---|
STACX_ROLLOUT_ID | train_{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 var | Modes | Effect |
|---|---|---|
STACX_REJECTION_SAMPLINGrollout_manager.py:122 | 1/all, 0/none/"", warmup_only | Keep 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_FILTERrollout_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=1bypasses reuse; every iter rolls out with the improving student, savingtrajectory_sft_samples.ptplus a per-iter…_iter_NNN.pt(gated bySTACX_SAVE_PER_ITER_PT, default on). - Reuse (iter-SFT):
STACX_REUSE_ROLLOUT=1always loads the.pt;STACX_REUSE_PER_ITER_DIRreplays a directory of per-iter dumps in sequence. Without either,rollout_id > 0auto-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 / variable | Read at | Default | Effect |
|---|---|---|---|
--env-cluster-layout | __init__.py:52 | None | Node → GPU/CPU/mem layout for the global pool; None ⇒ no gating. |
--env-num-cpus / --env-memory-gb | __init__.py:68 | 0 / 0.0 | Fallback CPU/mem for shorthand layout entries. |
--evaluator-batch-size | train.py:1269 | None→1 | Max tasks per batch; None ⇒ all tasks of a type in one batch. |
--evaluator-concurrency | train.py:1270 | None | Per-evaluator concurrency for installed scaffolds (→ Resource.concurrency). |
--rollout-ckpt-dir | task_scheduler.py:512 | None | Directory for the write-only partial rollout checkpoint. |
STACX_DAGGER_ENV_CONCURRENCY | evaluator_config_dagger.py:71 | 4 | In-house SWE evaluator concurrency (semaphore ceiling). |
STACX_REJECTION_SAMPLING | rollout_manager.py:122 | 0/none | Reward>0 survivorship filter (1/all/warmup_only). |
STACX_FINISH_ONLY_FILTER | rollout_manager.py:159 | 0 (scripts 1) | Keep only _termination_reason=="finish". |
STACX_FORCE_FRESH_ROLLOUT | rollout_manager.py:236 | 0 (scripts 1) | Bypass .pt reuse — fresh rollout every iter. |
STACX_REUSE_ROLLOUT / STACX_REUSE_PER_ITER_DIR | rollout_manager.py:229,218 | 0 / "" | Always load saved .pt / replay a per-iter dump directory. |
STACX_SAVE_PER_ITER_PT | rollout_manager.py:423 | 1 | Also save a per-iter trajectory snapshot for forensics/resume. |
STACX_SHUFFLE_SEED_BASE | rollout_manager.py:301,490 | 42 | Base seed for the per-iter deterministic chunk shuffle. |
STACX_KL_DUMP_DIR | task_scheduler.py:908 | "" | Per-iter student-trajectory dump dir (offline analysis); empty ⇒ off. |
7 Class & Function Reference
| Symbol | Location | Description |
|---|---|---|
TaskScheduler | scheduler/task_scheduler.py:127 | Batch scheduler — registry, pending queue, strategy, pool; execute_rollout/execute_eval/run_all. |
fifo_batching | scheduler/task_scheduler.py:60 | Default strategy — group by task_type in encounter order, seal at batch_size. |
BatchingStrategy | scheduler/task_scheduler.py:57 | Callable[[list[Task], dict[str,EvaluatorSpec]], list[Batch]] type alias. |
Batch | scheduler/task_scheduler.py:43 | Same-type task group destined for one evaluator. |
EvaluatorSpec | scheduler/evaluator.py:33 | Blueprint: handler + resource + run/eval_resource + batch_size + default_stopping. |
Evaluator | scheduler/evaluator.py:197 | Ephemeral per-batch executor; runs init→run→eval→cleanup per sample under a semaphore. |
Resource / NodeSpec / PlacementToken | scheduler/resource.py:18/71/81 | Budget-or-demand descriptor / node spec / allocated slot. |
PlacementPool | scheduler/resource.py:137 | Two-mode pool: global batch gating + local per-step tokens; try_acquire_batch, max_tokens_for. |
Task / TaskConfig / StoppingCriteria / TaskStatus | scheduler/task.py | Unit of work + generation config + loop-termination conditions + lifecycle enum. |
build_task_scheduler(args) | scheduler/__init__.py:97 | Factory — loads specs from --evaluator-config-path, builds the global pool, returns a TaskScheduler. |
classify_abort / classify_termination | rollout/termination_summary.py:143/78 | Exception → termination reason; (reward, reason) → failure class. |
Related pages: Rollout Architecture · Inference Backend · Sandbox Management · Algorithm Recipes.