8Batch Evaluation & Job Queue
Submit a graph as an eval run; GPU-budget-gated subprocess execution with per-episode persistence; one queue shared across all Claude / canvas sessions hitting the same backend.
The capabilities so far cover authoring a graph and watching one run go. This one covers the other half of the research loop: scoring a graph at scale โ N episodes from a benchmark split, K worker subprocesses for parallelism, GPU budget gating so concurrent sessions don't OOM each other, and per-episode logs you can ship to a teammate without re-running.
Design docs: Batch Eval System ยท Server Mode (worker-pool fan-out lives here) ยท ADRs: eval-001 / eval-002 / eval-003 / eval-004
1. Problem
Single-graph canvas Play answers "does my agent move?". Headline numbers โ SR, SPL, nDTW on a 100-/500-episode split โ require a different runtime shape:
- Reproducibility: each episode starts from a clean simulator state; failures in episode N don't leak into N+1.
- Parallelism: simulators are slow; K worker subprocesses turn a 6-hour serial run into 1 hour at K=6.
- GPU budget gating: 24 GB of VRAM and three Claude sessions all want to launch eval jobs โ somebody has to queue, and the wait can't crash the model in a fourth session.
- Lifetime independent of the backend process: a run that takes 4 hours can't die because the user reloaded the canvas. So each run is its own subprocess.
- Per-episode persistence: when episode 47 reports SR=0 you want to read that episode's log without re-running, and without scanning a 50 MB interleaved JSONL.
2. Architecture โ Two Layers
The capability is two cleanly-separated layers stacked together:
Layer A โ JobScheduler (in backend)
- Singleton owned by
ProcessServices. Per-secondtick()loop. - Admission rule: per measurable resource (VRAM + RAM), calibrated
estimate โค measured_free โ ฮฃ reservationsAND canvas-lock not held; estimates come from run-fed calibration, with declaredmarginal_vram_mbas the fallback rung. See the JobScheduler design doc. - Cross-session: every
POST /api/eval/v2/startfrom every Claude / browser session hits the same queue (ADR-eval-003). - Spawns each admitted job as a fresh subprocess (
python -m app.eval_subprocess_main --run-dir โฆ). - Refcounts shared singletons; auto-unloads job-loaded ones at refcount-zero (canvas-loaded ones stay).
Layer B โ BatchEvalRunner (in subprocess)
- Reads
spec.json+shared_urls.jsonfrom itsrun_dir. - Attaches to parent backend's shared singletons via
register_remote_nodesetโ no duplicate VRAM spend. - Iterates the episode list, leases one
WorkerHandleper episode fromEnvWorkerPoolwhenworker_count > 1(ADR-eval-002). - Each episode runs through a fresh
LoopRunnerโ same dataflow engine as canvas Play. - Per-episode
ExecutionLoggerโ self-containedepisodes/ep{NNNN}/on disk. - Touches
_DONEon clean exit; absence + dead PID โaborted.
The split means the backend stays small and stateless about the run (just a queue entry + a PID + a refcount), while the eval subprocess owns the long-lived state. A backend restart only loses the queue โ running jobs keep going on disk.
3. Run-Dir Layout (ADR-eval-004)
Every run gets a directory at outputs/eval_runs/{run_id}/ where run_id is a second-precision timestamp (e.g. 20260518_143052; collisions get _2, _3, โฆ appended). The layout is shared between the backend (writer of inputs) and the subprocess (writer of outputs):
outputs/eval_runs/{run_id}/
โโโ spec.json โ backend writes pre-spawn: full JobSpec + GraphDefinition.to_dict()
โโโ shared_urls.json โ backend writes pre-spawn: {nodeset_name: auto_host_url}
โโโ graph.json โ BatchEvalRunner writes: run-level graph snapshot
โโโ summary.json โ subprocess writes: running snapshot, per-episode rows, terminal
โโโ _DONE โ subprocess touches LAST on clean exit (absence โ aborted)
โโโ episodes/
โโโ ep{NNNN}/ โ one self-contained subdir per episode
โโโ log.jsonl โ per-node firing log, never interleaves across workers
โโโ assets/ โ image artefacts from this episode
โโโ episode.json โ this episode's row of summary.json (self-describing)
Self-containment is the load-bearing property of ADR-eval-004: copy a single ep{NNNN}/ directory to a teammate and they can replay it standalone. The pre-ADR layout interleaved all workers into one root log.jsonl, which made selective replay essentially impossible at worker_count > 1.
4. JobScheduler โ Admission & Lifecycle
4.1 Submit
JobScheduler.submit(spec) validates the spec, allocates a fresh run_id, writes spec.json + shared_urls.json into the run dir, and enqueues a _QueuedJob. It does not spawn โ admission is the next tick's responsibility, so even a same-second flood of submits is serialised.
spec shape (assembled by api/execution/eval.py):
{
"eval": { graph_name, selectors, dataset, split,
episode_count, step_budget, start_episode_index,
worker_count, per_step_budget_sec,
episode_indices, episode_selectors },
"scheduling": { marginal_vram_mb, exclusive_gpu, priority },
"graph": GraphDefinition.to_dict(),
"_shared_urls": { nodeset_name: auto_host_url }, # also split out to shared_urls.json
"active_workspace_dir": str | None, # architect-skill overlay
}
4.2 Tick โ admission rule
One tick() per second from the FastAPI lifespan. For each queued job, in priority order:
- Canvas-lock check: if a canvas Play is running (
ExecutionGuard.current()['mode'] == 'canvas'), no new jobs admit. Set viaset_canvas_lock_callback. Canvas always wins โ preempts even high-priority queued jobs. - Budget check: per measurable resource, calibrated
estimate โค measured_free โ ฮฃ reservations(measured admission, 2026-07).marginal_vram_mbis now the declared fallback when calibration is incomplete; already-loaded shared singletons count ~0 in the estimate. - Exclusive-GPU jobs: if a queued job has
exclusive_gpu=True, it admits only when no other jobs are running. Used by SLAM / VoxPoser-class graphs that don't share GPU memory cleanly. - If admitted, spawn the subprocess in its own session (
start_new_session=True); the child self-armsPR_SET_PDEATHSIGat startup (eval_subprocess_main.py ยท _set_pdeathsig) so the kernel reaps it if the backend dies (no orphan VRAM squatting).
4.3 Cancel + auto-unload
POST /api/eval/v2/runs/{run_id}/cancel from any session terminates the subprocess via SIGTERM โ SIGKILL. summary.json stays whatever the subprocess wrote last; absence of _DONE + dead PID is detected by the next status read as aborted.
The scheduler refcounts shared nodesets per consuming job. When a run terminates (clean, aborted, cancelled), each shared nodeset it consumed loses one refcount. Nodesets the scheduler itself loaded for that job (tracked in _shared_loaded_by_jobs) get auto-unloaded at refcount-zero on the next tick. Singletons loaded via canvas Play do not get touched by this path โ refcount drops to zero but the entry is not in the auto-unload set.
5. Worker Pool & Batched Inference
These belong topologically to Capability 3 โ Isolated Runtime Environments (they're the same subprocess + HTTP plumbing scaled up). The eval-side view is short:
worker_count = NโEnvWorkerPoolspawns N tagged copies of every env nodeset (parallelism="replicated") and registersRemoteEnvPanelProxy+ tagged URL for each. Non-env nodesets stay singleton.- Each episode leases one
WorkerHandlecarryingenv_panel_overrides+server_url_overrides. The leasedLoopRunnerconsults those before the global registry โ soset_episode/playand in-graph tool calls route to that worker's subprocess. - For pure-functional nodesets (
parallelism="shared", e.g. VLM forward) one subprocess hosts aBatchedInferenceServer. K parallel callers'forward()submissions coalesce into one stacked forward pass; K result slices scatter back. Opt-in viabatched: ClassVar[bool] = True+batch_dim: ClassVar[str] = "<input_port>"on the node class (ADR-server-003). worker_count = 1is bit-identical to single-worker โ empty overrides, no tagged proxies, single shared subprocess for everything.
6. Per-Episode Logs
Each episode runs through a fresh ExecutionLogger that writes to its own episodes/ep{NNNN}/log.jsonl. ExecutionPrinciples.suppress_nav_events = True in batch mode โ the WS exec_log event is silenced (it'd be useless: 100 episodes ร 14 nodes ร 500 steps ร 5 sessions would saturate any UI) โ but on-disk capture is unchanged.
For the consumer side (replay UI, summary dashboards), summary.json holds one row per episode with {episode_index, episode_id, scene_id, instruction, metrics, step_count, elapsed_sec, status, error, worker_id}. Each episode's episode.json is the same row, written by the subprocess on episode finish so partial summaries are readable even if the run is mid-flight.
7. HTTP API
All routes mounted under /api/eval/v2/ by api/execution/eval.py:
| Method + Path | Purpose |
|---|---|
POST /api/eval/v2/start | Submit a job spec. Returns run_id. Enqueues; does not spawn. |
GET /api/eval/v2/status | Status of the most recently started run in this session. |
GET /api/eval/v2/episodes | Episode-level snapshot of the current run. |
POST /api/eval/v2/stop | Legacy in-process stop (pre-ADR-eval-003 path). |
POST /api/eval/v2/runs/{run_id}/cancel | Terminate the subprocess of any run by id, across sessions. |
GET /api/eval/v2/queue | Full cross-session queue + running jobs. The Eval page's "Queue" tab. |
GET /api/eval/v2/runs | List past run summaries. |
GET /api/eval/v2/runs/{run_id} | Full summary + per-episode rows for one run. |
DELETE /api/eval/v2/runs/{run_id} | Delete a past run's directory (only when not running). |
POST /api/eval/v2/introspect | Resolve EvalConfig against a graph โ returns split list, episode count, env_nodeset, etc. Used by the Eval page to populate dropdowns. |
GET /api/eval/v2/export/{run_id} | Download a run as a tarball for teammate sharing. |
8. The /experiment:run Wrapper
Application code shouldn't talk to the eval API directly when running from a Claude session โ go through the /experiment:run <profile> -- <cmd> skill (.claude/experiment/). It:
- Reads
marginal_vram_mb/exclusive_gpufrom.claude/experiment/profiles.yaml(per-profile, defaults: full GPU + exclusive). - Allocates a backend port from the 8765โ8769 pool, brings up a dedicated
auto_hostif needed, exportsBACKEND_URL=http://127.0.0.1:<port>in the child env. - The wrapped command reads
$BACKEND_URLand submits via the API above. Never hardcode:8000โ that's the user's interactive backend. - On exit the wrapper releases the admit entry; backend lifetime is owned by
/experiment:teardown(no blanketpkill).
See .claude/experiment/README.md for invariants. Bare python tools/eval_*.py / uvicorn / auto_host bypass admission and risk OOM or port collision under concurrent sessions.
9. Key Files
| File | Role |
|---|---|
agentcanvas/backend/app/services/job_scheduler.py |
JobScheduler singleton โ submit / queue / tick / cancel / shared-nodeset refcount + auto-unload (ADR-eval-003) |
agentcanvas/backend/app/services/run_state_io.py |
File protocol shared between scheduler (parent) and runner (subprocess) โ write_spec / write_shared_urls / read_summary, atomic write helpers, full run-dir schema doc at top of file |
agentcanvas/backend/app/eval_subprocess_main.py |
Subprocess entry โ python -m app.eval_subprocess_main --run-dir โฆ; reads spec.json + shared_urls.json, attaches to parent's shared singletons, invokes BatchEvalRunner.execute(), touches _DONE |
agentcanvas/backend/app/agent_loop/eval_batch.py |
EvalConfig, EvalRun, BatchEvalRunner (imports ExecutionPrinciples from loop_runner.py, where it is defined); per-episode LoopRunner creation, _run_one_episode driving step-budget + per-episode timeout |
agentcanvas/backend/app/agent_loop/env_worker_pool.py |
EnvWorkerPool, WorkerHandle โ N-handle lease/release (ADR-eval-002) |
agentcanvas/backend/app/api/execution/eval.py |
/api/eval/v2/* endpoints (start / stop / status / runs / queue / cancel / introspect / export). Assembles spec dict, calls JobScheduler.submit |
agentcanvas/backend/app/api/execution/eval_storage.py |
Past-run JSON read/write helpers โ backs GET /runs, GET /runs/{id}, DELETE /runs/{id} |
agentcanvas/backend/app/services/test_job_scheduler.py |
JobScheduler tests โ admission rule, canvas-lock preemption, refcount auto-unload, cancel path |
agentcanvas/backend/app/services/test_run_state_io.py |
Atomic-write + concurrent-read tests for spec.json / shared_urls.json / summary.json |
.claude/experiment/profiles.yaml |
Per-profile marginal_vram_mb / exclusive_gpu declarations; unknown names fall back to full-GPU + exclusive_gpu |
Status
| Item | Status | Notes |
|---|---|---|
Graph-driven eval (BatchEvalRunner over LoopRunner) | Done | ADR-eval-001 |
Worker-pool fan-out (EnvWorkerPool, tagged proxies, worker_count > 1) | Done | ADR-eval-002 PB |
Batched inference tier (BatchedInferenceServer, batched/batch_dim) | Done | ADR-eval-002 PC; pure-functional contract enforced by register_node |
| Subprocess-per-run scheduler (cross-session admission) | Done | ADR-eval-003; tick=1s, FIFO+priority |
Per-episode log layout (self-contained ep{NNNN}/) | Done | ADR-eval-004 |
| Canvas-lock preemption | Done | ExecutionGuard.canvas mode blocks new admissions |
| Shared-singleton refcount + auto-unload | Done | Job-loaded singletons released at refcount-zero; canvas-loaded stay |
PR_SET_PDEATHSIG on subprocess | Done | Kernel-reaps eval children when backend dies โ no orphan VRAM |
Cross-session cancel (POST /runs/{id}/cancel) | Done | Any session can cancel any run by id |
Active-workspace overlay (ACTIVE_WORKSPACE_DIR in subprocess) | Done | ADR-components-009; used by architect skills |
Run export tarball (GET /export/{run_id}) | Done | Single download for teammate sharing |
| Backend-restart resilience (running subprocess survives) | Done | Subprocess owns the run; backend restart only loses the in-memory queue |
| Backend-restart re-attach to running subprocesses | Partial | /queue doesn't auto-rehydrate from on-disk PIDs after restart; running jobs finish but the UI loses sight of them. TODO. |
| TODO #60 โ overlay-aware shared spawn | Partial | Eval submits with active_workspace_dir touching shared nodesets currently fall back to frozen URLs; ephemeral spawn path stubbed in JobScheduler.set_workspace_component_registry. |