Harness playground — interactive test cases against a live harness session
Ships the interactive half of execution-model.md's symptom 1 ("
runonly serves scorecards; a harness cannot be experimented with"). P1's experiment answered the batch shape (POST /groups,_adhoc); the playground answers the conversational one: place the harness once, then throw test cases at it and watch it work, live — no dataset, no graders, no cold dispatch per try.
The shape
POST /sandboxes {harness:{id,version?,image?,conversation?}, runtime?}
boot: provision image → warm-install harness → session run
(service harness: ensure warm topology on the named runtime)
POST /sandboxes/:id/tasks {task, fresh?} one test case / conversation turn → its own child Run
GET /sandboxes/:id/tasks/:taskId/trace?since=N the 2s live cursor (append-only buffer; sealed fallback)
GET /sandboxes · GET /sandboxes/:id the reattach surface (record + live meta + task summaries)
POST /sandboxes/:id/close teardown: abort in-flight task → seal → settle → dispose
MCP twins: create_sandbox (harness branch) · submit_sandbox_task · read_sandbox_task_trace ·
list_sandboxes · get_sandbox (+ the existing sandbox_exec/close_sandbox). Web: the infra panel's
playground tab (boot form → task composer → live task cards), entry from the harness detail header.
Ledger model (nothing new — deliberately)
- The session is the P6 sandbox run unchanged:
Run{kind:"sandbox", lifetime:"session"}, TTL on the row, caps, the durable reaper, trajectory sealed at teardown. A harness session stamps the REAL harnessid@versionon the harness column,attach:["exec","tasks"], and keepscaseId/session.imageas the concrete container image. - Each test case is
Run.newSessionCase:kind:"eval",class:"interactive",lifetime:"task",trigger:"playground",group:{id: <session runId>, role:"case"}— the agent-turn idiom (O1) applied to the harness under test. Born running (the warm container starts synchronously; provision happened at session create).caseSpecpersists the prompt case (env:{kind:"prompt"},graders:[]) so the run detail shows what was asked; settle reusessucceed/fail, sorun.submitted/run.completed/run.failedfacts, billing (billingCharges→ budget settle + usage meter) and the run-detail page all come for free. Later judge-scoring / "promote to scorecard" needs no new data model. - Evidence: the child's events seal as its own trajectory (
source:"run") at settle — partial trace included on failure/cancel. The session's own trajectory holds onlytask.start/task.endboundary markers (pointers, no duplication).
Liveness — how "watching it work" actually works
ComputeHandle.execStream?(contracts): optional streaming exec — exec's result contract with chunks delivered as they arrive.DockerComputeHandle/LocalComputeHandleimplement it over one shared spawn core (packages/drivers/src/spawn.ts— the hardened close-first/exit-grace/group-kill/124 semantics the echo paths evolved).ClaudeCodeHarnesspicks the incremental path whenexecStreamis present: stream-json parsed PER LINE (ChunkLineQueue) and eachTraceEventyielded immediately. Same mapper, same events — the buffered eval path is untouched.CommandHarnessstays buffered in v1 (its platform-tracecollectTracewould double-report against incrementallogevents; a follow-up rung).- The session service appends each yielded event to the task's append-only buffer; the web polls
trace?since=<cursor>every 2s. The cursor is a plain array index;sinceomitted = full replay (the panel unmounts on tab switches — all durable state is server-side). After settle the SAME endpoint serves the sealed trajectory, so a refresh mid-completion is lossless;done:truestops the poll.
Discipline (the parts that are easy to get wrong)
- One task at a time per session (409 naming the active run): one container workdir sequence, one warm toolchain — parallel tasks would contend and muddy the live feed. Parallelism = more sessions.
- Case independence with a warm container:
scopedComputeHandle(handle, "tasks/<n>")rebases relative cwd/paths per task (docker-wneeds the dir to exist — mkdir once per rebased cwd). AbsoluteworkDirspecs opt out of isolation (documented caveat). Installed toolchain/deps stay warm at the container level. - Warm-install-before-record: provision →
mkdir work→harness.install→ only then the ledger row. A failed install disposes the container and leaves nothing (HARNESS_INSTALL_FAILED). - Secrets never persist: the composition-root resolver (registry get →
resolveHarnessSecrets→resolveSpecModelmodel binding →makeHarness(sandboxInstall)) keeps resolved values only in the process-local session map; they reach the container viaRunContext.apiKeyEnv/ spec env (docker exec -e). Records, caseSpec, traces and markers carry none.harnessAuthEnvpicks theHARNESS_AUTH_ENV_VARSvocabulary from workspace→personal secret tiers — the loginless container's substitute for a machine login. (Pre-existing docker property:-evalues are visible in the host process table during an exec.) - Admission:
budget.admit(402) before any child record;budget.releaseif the create fails. One admit = one metered run, settled from the trace's own cost lines like every run. - Teardown mid-task: close/expiry aborts the drive (
AbortSignal), waits a bounded grace for the child to settlefailed{CANCELLED}, seals partial evidence, then disposes. If the drive is stuck on an exec, the dispose kills the container and the child still settles (late, but terminal). - Recovery:
canRedispatch()excludesplacement.where === "driver"— a CP crash must tombstone an orphaned playground case (failed{INTERRUPTED}), never re-dispatch a prompt case through the backend lane. The in-memory live buffer dies with the process (rung-1 cost, same as session traces). - Imageless specs:
kind:"process"harnesses (claude-code) declare no image —harness.imageon the create body supplies one (400 with the fix named otherwise).makeHarness(..., {sandboxInstall:true})makes built-ins install their CLI into the bare image.
v1 limits (named, not implied)
- Placement is the P6 driver lane (docker on the control plane,
EVERDICT_SANDBOX_DRIVER=dockeropt-in). Dispatching a session to a tenant runtime is a later rung, same record/UI (the placement-ladder rule). - The synchronous warm install can hold
POST /sandboxesfor tens of seconds (npm/pip). An async "warming" state is a later rung. - Conversational continuity exists (see the Conversations section) for
conversationalprocess harnesses (claude-code) and service harnesses;CommandHarness/other CLIs still refuse conversation mode by name. - No envelope admission on session create (no
causedByRunIdinput today); tenant budget only. CommandHarnessevents arrive at settle (buffered);ClaudeCodeHarnessstreams.
Conversations — multi-turn against the harness under test
The playground's second mode: instead of independent test cases, every submitted task continues ONE
conversation. The session picks its mode at boot and never flips; the web renders the same feed chat-shaped
(turn bubbles, the reply as the prominent message). Both flavors share the routes above, the one-at-a-time
409, budget admission, and the child-run-as-monitoring-handle — a conversation turn is
Run.newSessionCase(role:"turn") (caseId turn-<n>, group.role:"turn" = dependent evidence; boot
recovery never re-dispatches a turn, and aggregations over role:"case" children must not ingest turns).
Process harnesses (harness.conversation: true) — continuity is the harness's own resume mechanism:
- The contract is
RunContext.conversation: {resume?, onToken?}(in-process only, likesignal) plus theEvaluableHarness.conversationalcapability marker. A harness without the marker refuses conversation mode at create, BEFORE any container is provisioned — silently-fresh turns would be a lie.CommandHarnesshas no marker in v1. ClaudeCodeHarnessimplements it:claude --resume <session-id>continues the thread, and the session id is captured from the stream-json init AND result lines (claudeSessionId, last-wins — a resumed run mints a NEW id). The token lives only on the process-local session state (a CP restart orphans the session anyway — the existing honest-failure stance).- One stable workdir: conversation turns share
scopedComputeHandle(handle, "conversation")(claude keys its session store off the cwd —tasks/<n>rebasing would break resume structurally).fresh: trueon a submit starts a new thread while deliberately keeping the workdir — "reset the chat, keep the environment".
Service harnesses (front-door conversations) — continuity is session-stable wiring:
- A
kind:"service"harness ref routes to the front-door branch (this is also what fixed the old misleading 404): the spec is secret- and model-resolved exactly like the dispatch lane, the topology environment comes from the SHARED per-rt:<tenant>:<id>@<version>memo (oneTopologyRuntimeinstance for the eval lane AND conversations — one warm pool, one idle sweeper), andFrontDoorSession(@everdict/topology) drives it behind the structuralServiceConversationport. Boot =ensureTopology+ the front-door endpoint (+ the per-SESSION target when the spec declares one — one browser for the whole conversation); each turn re-callsensureTopology(touch-on-use — an active conversation cannot idle out). - The wiring split IS the conversation: session-stable = the isolateBy vars (
thread_id/key_prefix/object_prefix/schema, derived from the SESSION run id — onethread_idacross submits is what makes a checkpointing agent resume) +stream_channel/minio_prefix+ target coordinates; per-turn fresh =run_id, the trace correlation key,callback_url(the rendezvous holds one waiter per key). A session-stablecontextId(e.g.{{thread_id}}) pulls the conversation's CUMULATIVE trace, so the session slices each turn's delta past what earlier turns already returned.freshis refused (400) — a service conversation's thread is its session; open a new session to start over. - The ledger row:
trigger:"frontdoor"(its OWN capacity pool,EVERDICT_FRONTDOOR_MAX_PER_TENANT/_MAX_TOTAL— a warm-topology slot on a tenant cluster is a different scarcity than a CP container),session.conversation:true,session.image= the front-door service's image, NOcomputeId(the crash-path reaper settles row-only; the orphan sweep scans both pools).exec/snapshot/git-pushrefuse by name — there is no container. Placement is a REGISTERED workspace runtime only (runtimerequired, nomad/k8s with atraceSource= topology-capable) — the control plane deliberately hosts no topology. - The assistant's reply is the front-door result channel (
responseText); the turn's evidence buffer is infra marks → the agent's trace (inline or pulled delta) → the reply as amessageevent, solastAssistantText, the chat UI and the sealed trajectory all read it like any harness turn. A turn past its budget fails withcompletion-timeout(+runtime.diagnosenaming a sick service); the SESSION stays alive for the next message. Trace-completion (completion.mode:"trace") harnesses are refused — their front-door returns no reply to converse with. - Known v1 leak: a crash-orphaned held target (the per-session browser) is not reaped by the row-only settle — it is bounded by the target job's own lifecycle on the cluster.
Delegation profiles — booting a conversation from a registered environment
The conversation lane's third entry (beside a process harness and a service harness): POST /sandboxes {profile:{id}, brief} boots a delegation capability — a registered work environment (image · which
conversational agent · model binding · env/secrets · standing instructions) referenced once instead of
re-specified per call. It is an OVERLAY on the target axis, so a delegate works anywhere a member can — its
own image, an adopted environment, a world it continues, or a world it founds (the profile's image being the
genesis base). The session is a conversation by definition; the profile's workDir is the stable cwd
(turns run there directly — no tasks/<n>, no conversation/ rescoping, or the delegate walks away from the
context seeded beside it); the standing instructions and the rendered brief are written into that directory
BEFORE the ledger row exists, and the brief is sealed on the trajectory as a delegation.brief marker.
Design + the env-precedence rule: docs/architecture/capability-store.md §Fifth kind.
Agent worlds (W1) ride the same session
A sandbox session opened with world:{id} becomes a PERSISTENT environment: its filesystem is
snapshotted (host-side docker commit + push) into the managed image store and registered as an
environment-capability version, and the next world session boots from the latest snapshot —
expiry hibernates instead of losing state. See docs/architecture/agent-worlds.md.