feat(v1): client-side tasksets + stateless env server (v1-only)#2039
feat(v1): client-side tasksets + stateless env server (v1-only)#2039mikasenghaas wants to merge 7 commits into
Conversation
The env server no longer owns a taskset: the client (eval entrypoint, prime-rl orchestrator) loads the taskset once and ships each dispatched task's data on the run request; the server pydantic-validates it into the taskset's declared TaskData type and rebuilds the Task exactly as load() would. This removes the server-side task cache (and MAX_LAZY_TASKS), the requirement that infinite tasksets generate identical sequences in every pool worker, and the duplicate dataset load per worker. It also fixes resume-after-interrupt through the server path structurally: the client now dispatches and resumes in one coordinate system (task.data.idx), so tasksets whose load() filters rows (gappy idx) no longer rerun the wrong tasks and drop good traces (supersedes #2017). The legacy v0 bridge keeps task_idx addressing — its dataset genuinely lives server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…classes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d fields The dispatch payload is a plain model_dump: a TaskData subclass may not exclude fields (enforced at class definition), since an excluded field would vanish on the wire and the server would rebuild the task with silently-defaulted values. Harbor's task_dir — the only excluded field — now serializes (a host path in saved traces is harmless, and rows become rebuildable for replay). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a significant architectural change moving tasksets from server-side to client-side ownership. Additionally, an unresolved high-severity bug was identified where tasks sharing the same You can customize Macroscope's approvability policy. Learn more. |
| idxs = sample(list(range(info.num_tasks)), config.shuffle, config.num_tasks) | ||
| payloads: dict[int, dict] = {idx: {"task_idx": idx} for idx in idxs} | ||
| else: | ||
| group_scored = bool(tasks) and bool( |
There was a problem hiding this comment.
let's wait for #1939 to make group rewards obsolete?
…t-side tasksets re-land on top Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same design as before the multi-agent merge, adapted to episodes: the client (eval entrypoint, prime-rl orchestrator) loads the taskset once and ships each dispatched task's data on the run request; the server validates it into the taskset's declared TaskData type and rebuilds the Task exactly as load() would. The server keeps no task state (no per-worker load(), no idx cache, no MAX_LAZY_TASKS), and run_eval_server dispatches and resumes in data.idx — the same coordinate system as the local runner. TaskData subclasses may not exclude fields (rejected at class definition; harbor's task_dir un-excluded). The legacy bridge keeps task_idx addressing; run_group is untouched (legacy-only route). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| else: | ||
| group_scored = False | ||
| idxs = [task.data.idx for task in tasks] | ||
| payloads = { |
There was a problem hiding this comment.
🟠 High eval/runner.py:180
When two selected tasks share the same task.data.idx (including the valid default None), the payloads dict at line 180 keeps only the last task's serialized data, and every entry in idxs dispatches that same data via run_unit. Distinct tasks that collided are silently replaced, so the eval reports results for the wrong task set. This happens because payloads is keyed solely by idx, which is optional and not guaranteed unique by Taskset.select. Consider keying the dispatch by the task object itself (or a unique position) instead of idx.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/cli/eval/runner.py around line 180:
When two selected tasks share the same `task.data.idx` (including the valid default `None`), the `payloads` dict at line 180 keeps only the last task's serialized data, and every entry in `idxs` dispatches that same data via `run_unit`. Distinct tasks that collided are silently replaced, so the eval reports results for the wrong task set. This happens because `payloads` is keyed solely by `idx`, which is optional and not guaranteed unique by `Taskset.select`. Consider keying the dispatch by the task object itself (or a unique position) instead of `idx`.
Companion to the rebased PrimeIntellect-ai/verifiers#2039. A v1 env's taskset is loaded once, in the orchestrator: finite tasksets become the train source's shuffled example table (real tasks, not index ranges); an infinite taskset's generator is pulled per example. Each dispatched env-rollout ships its task's data (task.data.model_dump()) and the env server validates and runs it. The legacy (v0) bridge keeps its server-side dataset, task_idx addressing, and the run_group route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Rebased onto the multi-agent episode wire (#1939):
origin/mainwas merged wholesale and the change re-landed on top, adapted toEnv/Episode.task_data, the task's dumpedTaskData); the server pydantic-validates it into the taskset's declaredTaskDatatype and rebuilds theTaskexactly asload()would, then runs it through the env (slots/run_slot) as usual.run_eval_servernow selects and resumes tasks client-side, in the same coordinate system as the in-process runner (task.data.idx) — fixing the resume coordinate bug structurally (supersedes fix(v1): resume must match saved rollouts by load position, not data.idx #2017, see Verification).load()(a pool of N workers used to pull the dataset N times), no idx-addressed task cache, noMAX_LAZY_TASKS, and infinite tasksets no longer need to generate identical sequences in every pool worker — the one client-side generator is pulled lazily and each produced task is shipped to whichever worker runs it.TaskDatasubclass may not exclude fields from serialization (enforced at class definition) — an excluded field would vanish on the wire and the server would rebuild the task with silently-defaulted values. Harbor'stask_dir, the only such field in the tree, now serializes (rows also become rebuildable forreplay).Taskset.task_type()classmethod resolves the declaredTasksubclass off the generic (used by the server,Env.__init__, andloaders.task_type).task_idxaddressing — its dataset genuinely lives server-side — andrun_groupstays a legacy-only route, untouched.info.num_tasksis now only meaningful there.Verification
Repro taskset:
load()filters a raw 5-row dataset (likeLeanTasksetfiltering empty statements), sodata.idxis{0,1,3,4}while load positions are{0,1,2,3}.Before (main):
uv run eval gappy_echo_v1 --server -n 4 -r 1completes all 4 tasks;uv run eval --resume <dir>then reports "1 task(s), 1 rollout(s) owed", deletes the goodidx=4record, and re-runsidx=3—traces.jsonlends up{0,1,3,3}(a duplicate and a silently lost task).After (this branch, on the episode wire):
nothing to resume … all 4x1 rollouts already completed without error, episodes untouched ({0,1,3,4}, allok);idx=3episode and resuming re-runs exactlydata.idx 3and restores{0,1,3,4}.Companion prime-rl training on this branch pair: 5-step RL runs of
reverse-text-v1(finite) andalphabet-sort-v1(infinite) — see the companion PR's Verification.uv run pytest tests/v1 -m 'not e2e'passes.Breaking
runrequests taketask_data(the task's wire data) instead oftask_idx;task_idxremains only for the legacy bridge.EnvClient.runsignature changed accordingly. Migration: load the taskset in the client (vf.load_taskset(...).select(...)) and passtask_data=task.data.model_dump(mode="json").TaskDatafields can no longer setexclude=True— class definition now raises. Task data must survive the wire whole; harbor'stask_dir(the only affected field) is un-excluded and now appears in saved traces.InfoResponse.num_tasksisNonefor v1 servers (the client owns the taskset and its count); only the legacy bridge reports a count.Companion PR: PrimeIntellect-ai/prime-rl#3043.
🤖 Generated with Claude Code