feat(v1): streaming tasksets - Taskset.stream() and a windowed runner - #2582
feat(v1): streaming tasksets - Taskset.stream() and a windowed runner#2582faresobeid wants to merge 3 commits into
Taskset.stream() and a windowed runner#2582Conversation
A taskset whose tasks appear over time (a queue, a feed of owed work) overrides `stream()`: an async generator that awaits its source and returns when it drains; `streaming` reports the override. The eval runner consumes `stream()` for every taskset through `run_stream`, which replaces the one `gather_rollouts` call: slots run as their tasks arrive, the next task is pulled only while fewer than `-c` slots are in flight (back-pressure), the run ends with the stream, a failing slot cancels the rest and the stream is `aclose()`d on any exit. `--shuffle`/`--resume` are refused on a stream; `-n` bounds it by count. A static taskset takes the same path with every slot planned up front and no window, so nothing changes for it.
| ) | ||
| async with display: | ||
| results = await gather_rollouts(run_slot(slot) for slot in planned) | ||
| results = await run_stream(groups, run_slot, window) |
There was a problem hiding this comment.
🟠 High eval/runner.py:352
Streaming evals hang when a scheduled rollout fails before the concurrency window fills, instead of aborting and propagating the failure. run_stream waits for the next group from selected.stream() before checking pending tasks, so with -c 2 a failed first rollout can leave the runner blocked indefinitely if the feed does not yield another task; update it to wait on either the next group or an existing task completion and close the stream on failure.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/cli/eval/runner.py around line 352:
Streaming evals hang when a scheduled rollout fails before the concurrency window fills, instead of aborting and propagating the failure. `run_stream` waits for the next group from `selected.stream()` before checking pending tasks, so with `-c 2` a failed first rollout can leave the runner blocked indefinitely if the feed does not yield another task; update it to wait on either the next group or an existing task completion and close the stream on failure.
There was a problem hiding this comment.
Fixed in 4090736. run_stream no longer awaits the next group directly: the pull is its own task, raced (asyncio.wait(..., FIRST_COMPLETED)) against a future that the first failing slot resolves via a done-callback (so the wait stays O(1) for a static run's thousands of in-flight slots). A failure while the feed is quiet raises at once; the pull is cancelled and awaited before the stream is aclose()d, and the existing cancel-the-rest semantics are unchanged. Test: test_failing_slot_is_seen_while_the_feed_is_quiet — one failing slot then a feed that never yields again raises promptly (with and without a window) and the stream is closed.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ddf1e74. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a new streaming taskset capability and shared windowed execution infrastructure that changes production evaluation and service lifecycle behavior. Unresolved high-severity concerns include failure handling and missing system-prompt propagation for overridden streams. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
…unds rollouts, not tasks `run_stream` awaited the next group directly, so a slot failing while the stream was parked on its source went unobserved until the feed next yielded - possibly never. The pull is now its own task, raced against the slots' first failure (a done-callback resolves a future, keeping the wait O(1) for a static run's many slots); on failure the pull is cancelled and awaited before the stream is closed. The streaming planner yielded a task's `num_rollouts` slots as one group, so the window bounded tasks, not rollouts (`-c 1 -r 1000000` scheduled a million coroutines). `_plan_stream` (module-level, so it is testable) yields one slot per group; the display still grows by whole tasks. Tests: a feed that yields one failing slot then stays quiet raises promptly (both with and without a window); window=1 with 50 rollouts never has more than one slot alive.
…stream` is the public primitive After review: an eval entrypoint is a report on a finite set, not a process that waits for new tasks. Open-ended consumption of a feed is the caller's job — a service loop — built on the runner primitive, which is now public. - `vf.run_stream(source, run, *, window)` (`verifiers/v1/utils/aio.py`, exported with `RunSlot`): consumes an async iterable under a concurrency window with back-pressure, cancels the rest on a failure, closes the source on any exit; the caller decides when the source ends. Generic over the item, so the eval runner's in-process and served paths and a service's `env.run_slot` all use it. - `vf eval` on a streaming taskset requires `-n/--num-examples`: it takes the next `n` tasks (never pulling an (n+1)th the feed may still be waiting for) and ends; without `-n` it refuses with a `ValueError` naming the flag — the infinite-taskset rule, extended. `--shuffle`/`--resume` stay refused. `open_run(num_examples=)` is an `int` again. - Runner: the slot groups indirection is gone (one item, one slot); `_take` takes a required `int`. - Tests: `test_take_is_the_next_n_of_a_feed`, `test_streaming_taskset_requires_num_tasks`; the e2e fixture is a live feed that never drains, so `test_streaming_taskset` (`-n 3`, in-process and served) proves the run ends on the 3rd without a hang or a 4th episode. `run_stream` unit tests drive it with tasks directly. - Docs (tasksets.md, evaluation.md): the scope split and a three-statement service example around `env.serving()` / `vf.run_stream` / `env.run_slot`.
| ) | ||
| yield from self.transform(tasks) if self.transform is not None else tasks | ||
|
|
||
| async def stream(self) -> AsyncIterator[TaskT]: |
There was a problem hiding this comment.
🟠 High v1/taskset.py:71
Streaming tasksets ignore config.system_prompt, so --env.taskset.system-prompt <file> sends their yielded tasks without the configured prompt and changes evaluation results. An overridden stream() bypasses Taskset.__iter__, where task.with_system_prompt(...) is applied; ensure the streaming path applies the same transformation before yielding tasks.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/taskset.py around line 71:
Streaming tasksets ignore `config.system_prompt`, so `--env.taskset.system-prompt <file>` sends their yielded tasks without the configured prompt and changes evaluation results. An overridden `stream()` bypasses `Taskset.__iter__`, where `task.with_system_prompt(...)` is applied; ensure the streaming path applies the same transformation before yielding tasks.

What
Taskset.stream()— the read path the eval runner consumes — and the windowed primitive behind it,vf.run_stream, public.Scope
vf evalis bounded; long-running consumption is the caller's job. An eval entrypoint is a report on a finite set — it never waits open-endedly for new tasks. On a streaming tasksetvf evaltherefore requires-n/--num-examples: it takes the stream's nextntasks (pulling each only while fewer than-crollouts are in flight, and never an(n+1)th the feed may still be waiting for), then ends and reports like any other run. Without-nit refuses (ValueErrornaming the flag — the existing infinite-taskset rule, extended);--shuffle/--resumeare refused too (no whole set to sample, no keys to resume against).A service that consumes a feed for as long as it runs owns that loop itself — its shutdown, where the results go — and is built on the same primitive the runner uses, not on the entrypoint:
Why the split: the eval entrypoint carries a run (
open_run/finish_run), a dashboard with a total,--resume, an exit code — all of which presuppose a finite set. What a service reuses is the runner primitive (bounded concurrency, cancel-the-rest, source close-out) andenv.run_slot(whole-episode retries,on_complete), so those are the public surface;run_streamis generic over the item so the in-process path, the served path (oneclient.runper slot) and a service'senv.run_slotall go through the one implementation. (This is the reshape after review: the first cut letvf evalrun until a stream drained.)Why
Production is a feed: an owed seat turn appears when the records change, not from a list. The data-flywheel environment carries its own scheduler for exactly this —
run.Loop.sweep/schedule/reconcile/_seat, thePlace/Placescapacities, the feed-wait loop,settleandSHUTDOWN_GRACE— ~330 lines whose only job is "which task is due now, bounded how, stopped how", re-implementing what the runner already owns (bounded concurrency,run_episode_with_retry,append_episode, shutdown). The runner could not consume a feed becauseTaskset.load()is sync (a feed cannot be awaited without blocking the loop) andrun_evalmaterialised the taskset (tasks = list(selected)) and planned every slot before the first rollout. An async-capableload()was rejected (breaks__iter__,head/shuffle, and every static taskset's contract).stream()is a separate hook soloadstays the small thing AGENTS.md wants, and the override is the declaration (no flag that can disagree with the code). Withrun_streampublic, the service keeps its loop and drops its scheduler;vf eval -n 20on the same taskset is the bounded spot-check.What is preserved
stream()override) takes the samerun_streampath with every slot planned up front, before the dashboard's first frame, andwindow=None: the semaphore alone bounds what runs, exactly as before — same admission order, same dashboard total from the start, same cancel-the-rest / wait-to-unwind semanticsgather_rolloutshad (folded intorun_stream; it had no other callers).INFINITE,-n,--shuffle,--resumebehave as today for it.Taskset.load,__iter__,head,shuffle,viewunchanged; the defaultstream()yieldsiter(self), sohead(2).stream()==iter(head(2)).client.runper slot; the streamed taskset lives in the main process as before.open_run(num_examples=)stays anint: a stream's count is its-n.vf.run_stream,vf.RunSlot(already the typeEnv.run_slot/Env.slotstake and return).Tests
tests/v1/test_taskset.py(deterministic, no model): a fixture taskset streaming off anasyncio.Queue, driven throughvf.run_streamwith tasks directly: the defaultstream()equals iteration (head(2)included) andstreamingisFalse/True;_take(…, n)yields the nextn, fewer if the feed ends first, never pulls an(n+1)th, and closes a started source; withwindow=2the 3rd task is not pulled while two runs are in flight (asserted on the source's cursor with the runs gated); results come back in submission order once the stream ends; a failing run cancels the others, re-raises, andaclose()s a stream whose feed is still waiting — also when it fails while the feed is quiet (the pull is raced against the runs' first failure, so a quiet feed cannot hide it); a task's-rrollouts are windowed one by one (window=1, 50 rollouts, never more than one alive); cancelling the run whilestream()is parked on its feed closes it.tests/v1/test_e2e.py::test_streaming_taskset[run_v1|run_v1_server]: the fixture taskset (tests/v1/fixtures/echo_stream_v1.py) is a live feed that never drains (the echo phrases every 0.5 s, cycling); with-n 3the run ends after the 3rd task, in-process and served → 3 ok episodes, 3 rows intraces.jsonl(a pull past the 3rd would land as a 4th episode, not a hang).test_streaming_taskset_requires_num_tasks(offline): no-n→ValueErrornaming-n/--num-examplesbefore any rollout.test_streaming_taskset_refuses_shuffle_and_resume[shuffle|resume](offline):ValueErrorbefore any rollout.uv run pytest tests/v1 -m "not e2e" -n auto: 98 passed; the two streaming e2e cases plustest_single_turn[null-harness-in-subprocess],test_env_id_best_of_n,test_multi_agent_env_server(static path, in-process and served): passed. The documented service pattern (env.serving()/vf.run_stream/env.run_slot) run for real against the fixture feed withwindow=2: 4 ok episodes, each published throughon_complete.uv run ruff check,ruff format --check,pre-commit run --all-files,ty check verifiers: clean.Note
Medium Risk
Changes core eval scheduling and concurrency for all runs (via
run_stream), not only streaming tasksets; mistakes in windowing or source closure could affect rollout counts or leave feeds hanging.Overview
Adds streaming tasksets: subclasses override async
Taskset.stream()(detected bystreaming) so tasks can arrive over time whileload()may stay empty. The publicvf.run_streamhelper runs an async source with optional window back-pressure, ordered results, fail-fast cancellation, andaclose()on the feed.run_evalnow drives all rollouts throughstream(): streaming runs_take(..., -n)→_plan_stream→run_stream(dashboard slots grow as tasks arrive;max_concurrentis the pull window); static tasksets still plan every slot upfront and usewindow=None(semaphore-only, same behavior as the oldgather_rolloutspath). Streaming and infinite tasksets require-n;shuffleandresumeare rejected on streams.Docs cover streaming vs bounded
evalvs long-livedrun_streamservices;EchoStreamTasksetand unit/e2e tests exercise back-pressure, bounds, failures, and config errors.Reviewed by Cursor Bugbot for commit 0982604. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add
Taskset.stream()async hook and windowedrun_streamrunner for v1Taskset.stream()default async hook andTaskset.streamingproperty in taskset.py; subclasses overridestream()to emit tasks lazily.run_streamconcurrency utility in aio.py that pulls items with back-pressure, limits pending runs via a window, returns results in submission order, and cancels outstanding work plus closes the source on failure or cancellation.run_evalin runner.py to consume the nextnum_tasksstreamed tasks via_takeand_plan_stream, execute both static and streaming evals throughrun_stream, and reject missing bounds, shuffle, and resume for streaming tasksets.RunSlotandrun_streamfrom theverifiers.v1public API.run_streaminstead of the oldgather_rolloutshelper; any out-of-tree callers ofgather_rolloutswill break since it was removed from runner.py.Macroscope summarized 0982604.