Streaming case pipeline — kill the batch barriers, release compute early
Status: doc-first SSOT. Successor to execution-scoring-orchestration (which separated the concerns: execute / score / orchestrate). This doc removes the remaining serialization between those concerns: phase barriers in the batch pipeline and sandbox occupancy during non-compute work. Related: trace-sink (the observability round-trip this pipeline feeds).
Problem — measured, not hypothetical
The batch pipeline is already async at the API surface (202 + background track()), fanned out per case
(runSuite mapLimit), and backpressured at placement (Scheduler WFQ). What remains serial:
- Judge application is a barrier AND a serial loop.
ScoringService.applyJudgesrunsfor judge × for case, oneawaitat a time, and only starts after the entire batch finishes (scorecard-service.tsphasejudges). 100 cases × 2 judges × ~5s LLM call ≈ 17 min of pure serial tail; the slowest case gates judging of every other case. - Phase barriers. dispatch-all → judge-all → offload → export → finalize. Sandbox-bound work (execution) and I/O-bound work (judge LLM calls) never overlap.
- The sandbox is held during non-compute work. In
runner.runCasethe compute handle stays provisioned through grading — including graders that never touch the environment (trace/snapshot/judge). The most expensive resource (an isolated job) idles on network/LLM latency. Same shape inServiceTopologyBackend: the browser target is held through grading.
Principles
- A barrier is legitimate only where cross-case aggregation demands it —
summarizeScorecard/scorecardModels/ persist (finalize). Everything per-case streams. - Compute is the scarce resource. Release it at the last instruction that needs it. Scoring over observations (trace + snapshot) is I/O-bound and must not hold a sandbox.
- Observations are materialized before release — anything a post-release grader needs from the environment (today: the os-use screenshot ref) is captured into the observation bag first.
- No semantic drift. Per-case score order stays deterministic; judge failure semantics
(
error.phase="judges"), supersede, and "missing judge = silent skip" are preserved.
Design
D1 — per-case scoring core + case-axis parallelism (ScoringService)
resolveJudges(tenant, selections)— resolve specs once up front (missing → skipped here, not per case).applyJudgesToCase(tenant, evalCase, specs, result, runtime?)— judges applied sequentially within a case (deterministic score order), cases run in parallel (bounded,caseConcurrencydefault 4 — provider rate-limit guard).createJudgeStream(tenant, dataset, judges, runtime?)→{ push(result), settle() }— the streaming unit.pushfires a bounded task immediately;settlejoins all tasks and rethrows the first error.applyJudges(kept for ingest + back-compat) = push all results, settle. One core, two consumption modes.
D2 — streaming judges in the live batch (ScorecardService.track)
- Specs pre-resolved before
runSuite;onResultpushes each finished case into the judge stream — judging overlaps dispatch, the slowest case no longer gates the fastest. - After
runSuite:phase = "judges"→await stream.settle()— the barrier collapses to a join. A judge task error still lands onerror.phase="judges"after dispatch completes (same as today). - Supersede: after abort no further cases are pushed; already-launched tasks settle before persisting
(avoids racing
writeBackResultsagainst in-flight score mutation). Judge scores on a superseded partial result are harmless —superseded ≠ succeeded, no baseline/leaderboard pollution.
D3 — early compute release (runner.runCase + topology backend)
Gradercontract gains an optional marker:needsCompute?: boolean— declaredtrueby the outcome family that executes commands in the environment (tests-pass/command/swe-bench/script-score). Undeclared = observation-only (trace/steps/cost/latency/browser/judge).runCaseorder: run → snapshot → gradeneedsComputegraders → materialize the os-use screenshot (ref → base64, into the grading snapshot only — the stored snapshot stays ref-only, no record bloat) → release compute → grade observation-only graders (judge LLM waits no longer hold the sandbox). Thefinallyrelease stays (idempotent via flag) — the invariant "ComputeHandle is always released in a finally" is unchanged.ServiceTopologyBackend: dispose the browser target right afterobserve()(observations are in hand), before grading — same idempotent-release pattern.
D4 — 2-phase trace collection (traceRef)
The user-facing model: phase 1 = run the harness against the dataset; phase 2 = collect the trace the harness exported to an observability platform near the runtime. Two modes, one knob:
- Contract.
RunContext.runId—runCasemints (or receives) the correlation id and hands the same value torun()(harness injects it asEVERDICT_RUN_ID/everdict.run_id) and to collection.EvaluableHarnessgains two optional hooks:traceSource()(the platform coordinates + collect mode, from the harness spec) andcollectTrace(runId)(the actual pull).CommandHarnessSpec.tracegainscollect: "job" | "control-plane"(default"job"). - Mode
job(default — no regression, in-job pull moved after release).CommandHarness.run()no longer pulls at the generator tail;runCasecallscollectTrace(runId)after compute release and appends the platform events before observation-only grading. The sandbox is free during OTel/MLflow flush lag. Outcome (needsCompute) graders grade before release on the exec-only trace — they never read the trace, so this is semantically identical. - Mode
control-plane(opt-in — the job ends at execution end). The agent skips collection AND observation-only grading entirely; theCaseResultcarries compute-bound scores + snapshot (os-use screenshot materialized into the result, since the control plane can't reach the sandbox) +traceRef {kind, endpoint, runId}.executeCase(shared by run + scorecard) completes the result: pull viabuildTraceSource→ append events → reconstruct the deferred observation graders fromcase.graders(sameneedsComputepartition rule, both sides deterministic) → grade. Settle (costOf) runs after completion, so cost accounting sees the collectedllm_callevents. Judges then stream over the completed result unchanged (D2). - Why the knob is on the harness spec (locality). The trace endpoint is often cluster-internal
(reachable only from the runtime network) — that is exactly why traces are loaded "near the runtime".
Only the harness owner knows whether the endpoint is control-plane-reachable; default
jobkeeps pulls inside the runtime network. - Failure semantics.
jobmode: pull failure fails the case (unchanged).control-planemode: soft-degrade — anerrorevent is appended and observation grading proceeds; execution artifacts (snapshot + ground-truth scores already produced in-job) are never thrown away, andcaseVerdictauthority ranking means a missing trace cannot overturn a ground-truth pass. An inlinejudgecase grader cannot be reconstructed control-plane-side without its Judge → explicitskippedscore (registry judges — the main path — are unaffected). - Auth (
trace.authSecret). A SecretStore name on the spec (value = verbatimAuthorizationheader, the pull-ingest convention).jobmode:resolveHarnessSecretsresolves it into transienttrace.authat dispatch (the agent can't reach the SecretStore; same discipline as env{secretRef}— value in the job payload only, never the registry).control-planemode: the name rides ontraceRef.authSecret(values are never persisted —CaseResultis stored) andcollectDeferredTracere-resolves viasecretsFor(tenant); a missing secret is a visible soft-failure, never a silent unauthenticated pull. - Retry (flush lag). Both collect paths retry an empty pull (3 attempts, 2s backoff — emptiness is
the flush-lag shape; hard errors stay conclusive). A still-empty control-plane collect appends an
errorevent naming the correlation key instead of silently scoring 0. - Tag correlation (
correlate:"tag", mlflow). Real platforms mint their own trace ids, so an everdict-mintedrunIdcan never equal one. A real instrumented agent instead tags its trace witheverdict.run_id=$EVERDICT_RUN_ID(SDKset_trace_tag=PATCH /api/3.0/mlflow/traces/{id}/tags), andMlflowTraceSourceresolves it viaPOST /api/3.0/mlflow/traces/search(backtick tag filter;locationsis required →trace.experimentmust scope the search). Default"id"keeps the runId=trace-id (pull-ingest) convention. Live-verified as S4 (below).
D4 — verified live (real MLflow 3.14, scripts/live/trace-collect-mlflow.mjs)
The script boots ghcr.io/mlflow/mlflow:v3.14.0 (docker, auto-cleaned; or MLFLOW_ENDPOINT), seeds
"instrumented-agent" traces via MlflowTraceSink (create + OTLP spans — the sink-e2e-verified path), then:
S1 collect="job" — real runCase+CommandHarness+LocalDriver; the command sees the injected
EVERDICT_RUN_ID (round-trips into the git-diff snapshot) and collectTrace(runId) pulls the real spans
post-release (llm_call 42/7 tokens → steps/cost derived). S2 collect="control-plane" — the job returns
only traceRef + ground-truth scores; executeCase pulls from real MLflow and grades the deferred
steps/cost (no double-scoring). S3 dead endpoint → soft-degrade (error event, ground-truth preserved). S4 correlate:"tag" —
the seeded trace is tagged everdict.run_id (the real-SDK contract) and an everdict-minted runId (≠ trace id)
resolves through traces/search to the real spans, completing the deferred grading.
All PASS 2026-07-06.
D4 — command trace kinds are the full 5 (shipped; phoenix live-verified)
CommandTraceSpec accepts otel | mlflow | langfuse | langsmith | phoenix — the same 5 kinds as
buildTraceSource, which CommandHarness.collectTrace now uses directly (one factory, adapter-owned auth
header conventions: otel/mlflow verbatim Authorization, langsmith x-api-key). Phoenix requires
project (spans are only addressable per project) — it rides traceSource() → traceRef.project →
TraceSourceConfig.project, converging with mlflow's experiment on the config side. Live-verified vs a
real Arize Phoenix (scripts/live/trace-collect-phoenix.mjs, docker-booted): P1 collect="job" post-release
pull round trip + P2 collect="control-plane" completion, both PASS 2026-07-07.
D4 — OTel tag correlation (shipped; Jaeger live-verified, the full real-agent round trip)
CommandTraceSpec otel gains correlate:"id"|"tag" + service (mirroring mlflow's tag mode).
OtelTraceSource in tag mode searches the Jaeger query API
(GET /api/traces?service=…&tags={"everdict.run_id":…}&limit=1 — verified vs real Jaeger 1.62: the tags
filter matches resource/process tags, service is required, and the search response embeds full spans →
one request, reusing the existing {data:[{spans}]} parser). OTLP-native backends without a search API stay
id-correlated. Live e2e (scripts/live/trace-collect-otel.mjs) proves the full real-agent contract with
zero id coordination: no seeding, no injected runId — runCase mints the key, the command (a real
OTLP-exporting script) mints its own trace id and sets only the everdict.run_id resource attribute, and both
collect modes (O1 job / O2 control-plane) resolve it by tag search. PASS 2026-07-07.
D5 — per-case sink export streaming (shipped)
The trace-sink export (the last remaining batch barrier) now streams: each case is exported to the harness-selected platform the moment its judging completes, so the team sees traces/scores appear in their MLflow/Langfuse case-by-case while the batch runs, and a batch that dies mid-way has already exported its finished cases. Shape:
TraceSinkService.exportStream(tenant, ctx, attach?)→{push, settle}— setup (sink roster → per-harness selection → secret resolve →buildTraceSink) happens once at stream creation;pushfires a bounded per-casesink.export(ctx, [case])(default concurrency 2 — sink rate-limit guard);settlejoins and aggregates into the sameScorecardRecord.exportshape (status/url/per-case ids — no schema or web change). Export tasks never throw (unchanged isolation contract); a wholesale failure surfaces as the first case error promoted to the top-level message.exportScorecard(ingest + fallback) is reimplemented as push-all + settle over the same core.- Chaining:
JudgeStream.pushnow returns a per-case completion promise;ScorecardService.trackchainsjudged.then(() => exportStream.push(case))— exports always carry judge scores, and the case-completion pipeline is now executed → judged → exported with only aggregate/persist as the barrier. Wired viaScorecardServiceDeps.exportStreamFor; without it the live batch falls back to the old post-batchexportResults(no regression), and ingest stays batch-shaped. - Supersede: no new exports are launched after abort; already-launched ones are joined and recorded as a
partial
exportoutcome on the superseded record (traceability —superseded ≠ succeeded, no pollution).
Follow-ups (deliberately not in this pass)
- Durable batch orchestration on Temporal — per-case activities give restart resilience + horizontal control-plane scale; extend the existing runs pattern when batch sizes demand it.
- Capacity-derived dispatch concurrency —
runSuitedefault 4 is static; derive fromScheduler.capacity()when large clusters go underutilized.
Non-goals
- Force-killing in-flight backend jobs on supersede (separate problem, tracked in github-actions-trigger).
- Parallelism within a case's judges (usually 1–2 judges; case axis dominates).
- Changing where judges run (control-plane co-locate via
placementstays as designed in judge-placement-locality).