AgentCanvas / Pages / Developer Guide / Capabilities / 8Batch Evaluation & Job Queue
2026-07-05

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:

2. Architecture โ€” Two Layers

The capability is two cleanly-separated layers stacked together:

Layer A โ€” JobScheduler (in backend)

  • Singleton owned by ProcessServices. Per-second tick() loop.
  • Admission rule: per measurable resource (VRAM + RAM), calibrated estimate โ‰ค measured_free โˆ’ ฮฃ reservations AND canvas-lock not held; estimates come from run-fed calibration, with declared marginal_vram_mb as the fallback rung. See the JobScheduler design doc.
  • Cross-session: every POST /api/eval/v2/start from 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.json from its run_dir.
  • Attaches to parent backend's shared singletons via register_remote_nodeset โ€” no duplicate VRAM spend.
  • Iterates the episode list, leases one WorkerHandle per episode from EnvWorkerPool when worker_count > 1 (ADR-eval-002).
  • Each episode runs through a fresh LoopRunner โ€” same dataflow engine as canvas Play.
  • Per-episode ExecutionLogger โ†’ self-contained episodes/ep{NNNN}/ on disk.
  • Touches _DONE on 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:

  1. Canvas-lock check: if a canvas Play is running (ExecutionGuard.current()['mode'] == 'canvas'), no new jobs admit. Set via set_canvas_lock_callback. Canvas always wins โ€” preempts even high-priority queued jobs.
  2. Budget check: per measurable resource, calibrated estimate โ‰ค measured_free โˆ’ ฮฃ reservations (measured admission, 2026-07). marginal_vram_mb is now the declared fallback when calibration is incomplete; already-loaded shared singletons count ~0 in the estimate.
  3. 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.
  4. If admitted, spawn the subprocess in its own session (start_new_session=True); the child self-arms PR_SET_PDEATHSIG at 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:

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 + PathPurpose
POST /api/eval/v2/startSubmit a job spec. Returns run_id. Enqueues; does not spawn.
GET /api/eval/v2/statusStatus of the most recently started run in this session.
GET /api/eval/v2/episodesEpisode-level snapshot of the current run.
POST /api/eval/v2/stopLegacy in-process stop (pre-ADR-eval-003 path).
POST /api/eval/v2/runs/{run_id}/cancelTerminate the subprocess of any run by id, across sessions.
GET /api/eval/v2/queueFull cross-session queue + running jobs. The Eval page's "Queue" tab.
GET /api/eval/v2/runsList 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/introspectResolve 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:

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

FileRole
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

ItemStatusNotes
Graph-driven eval (BatchEvalRunner over LoopRunner)DoneADR-eval-001
Worker-pool fan-out (EnvWorkerPool, tagged proxies, worker_count > 1)DoneADR-eval-002 PB
Batched inference tier (BatchedInferenceServer, batched/batch_dim)DoneADR-eval-002 PC; pure-functional contract enforced by register_node
Subprocess-per-run scheduler (cross-session admission)DoneADR-eval-003; tick=1s, FIFO+priority
Per-episode log layout (self-contained ep{NNNN}/)DoneADR-eval-004
Canvas-lock preemptionDoneExecutionGuard.canvas mode blocks new admissions
Shared-singleton refcount + auto-unloadDoneJob-loaded singletons released at refcount-zero; canvas-loaded stay
PR_SET_PDEATHSIG on subprocessDoneKernel-reaps eval children when backend dies โ€” no orphan VRAM
Cross-session cancel (POST /runs/{id}/cancel)DoneAny session can cancel any run by id
Active-workspace overlay (ACTIVE_WORKSPACE_DIR in subprocess)DoneADR-components-009; used by architect skills
Run export tarball (GET /export/{run_id})DoneSingle download for teammate sharing
Backend-restart resilience (running subprocess survives)DoneSubprocess owns the run; backend restart only loses the in-memory queue
Backend-restart re-attach to running subprocessesPartial/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 spawnPartialEval submits with active_workspace_dir touching shared nodesets currently fall back to frozen URLs; ephemeral spawn path stubbed in JobScheduler.set_workspace_component_registry.
AgentCanvas docs