Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jordangarcia/27ed97af9fde3b35a750742dd5765ca0 to your computer and use it in GitHub Desktop.

Select an option

Save jordangarcia/27ed97af9fde3b35a750742dd5765ca0 to your computer and use it in GitHub Desktop.

Local Agent Control Plane: unify main-app and agent/ web-ui session management

Context

Two UIs drive the same agent runtime today, through incompatible control planes:

  • The main app (packages/client src/pages/agents.tsxAgentSession.tsx) creates sessions via POST /harness-sessions on packages/server, which provisions one sandbox (one agent-server process) per session and exposes its data plane only through a cookie-authenticated proxy.
  • The diagnostic web-ui (agent/packages/web-ui-react) talks directly to a single long-lived agent-server on :4001/:4000 and shows many sessions inside that one process.

The data plane is already shared — both UIs use the same agent-client package and wire protocol against the same agent-server HTTP/SSE API. The incompatibility is purely control-plane: who creates sandboxes, who knows where they live, who is allowed to see them.

Decision (Jordan): build a local control plane as a new package next to agent/packages/agent-server. It absorbs the local sandbox-management code currently living in packages/server/src/agent-sandbox/ (native/docker runtimes, registry, reaping) and exposes a Daytona-like HTTP API. packages/server's local adapter becomes a thin HTTP client of it — structurally the same as the Daytona adapter. The web-ui does NOT connect to packages/server at all. bun run prompt spins up a new agent (sandbox) per invocation.

Outcomes:

  1. A session started from the main app appears in the diagnostic web-ui (same control plane, same registry), with full drive (prompt/abort/stop) from the web-ui.
  2. Parallel agents locally: every session is its own sandboxed agent-server process, created via the control plane from any client (main app, web-ui, prompt CLI).
  3. packages/server stops being prescriptive about local process management; local looks like Daytona from its perspective.

Architecture

packages/server (gamma)        agent/packages/web-ui-react       bun run prompt
  LocalAgentSandboxAdapter        client pool + registry            sandbox-per-run
  (thin HTTP client,              (full drive + diagnostics)            │
   sibling of Daytona adapter)          │                               │
        └───────────────┬───────────────┴───────────────────────────────┘
                        ▼
        agent/packages/agent-control-plane   (new bun daemon, 127.0.0.1:4025, /v1/*)
          registry + sandbox.json persistence + reconcile + reap + SSE events
                        │ spawns (native: local-sandbox-child.ts detached; docker: docker run)
                        ▼
        child agent-server processes, one per sandbox, ports 4100–4999, /api/* each
  • Identity: id === name === tokenSandboxId (unchanged) — writer lease, liveness, and proxy routing in harness-session/ stay keyed on it.
  • Data plane unchanged: main-app client keeps going through the gamma proxy; web-ui/CLI hit http://127.0.0.1:<port>/api directly (agent-server already cors origin:'*', agent/packages/agent-server/src/index.ts:1148). Dev-only, loopback bind.
  • Daemon port 4025 (outside sandbox range 4100–4999 and 4000/4001 lanes).

Phase A — Shared contract + control plane daemon (agent/ only)

A1. Wire types in agent/packages/agent-protocol/src/sandbox.ts (re-export from index.ts). This is the coordination artifact; the daemon imports it, agent-client imports it, packages/server hand-mirrors it.

CONTROL_PLANE_DEFAULT_PORT = 4025
SandboxState = 'starting' | 'started' | 'stopped' | 'destroyed'   // matches LOCAL_SANDBOX_STATE_SEQUENCE
SandboxInfo = { id, name, state, sessionId: string | null, port, previewUrl,
                labels: Record<string,string>,  // origin: 'harness'|'web-ui'|'cli', harnessSessionId?
                createdAt, updatedAt, lastActivityAt, autoStopInterval, errorReason? }  // lastActivityAt/autoStopInterval mirror Daytona (A7)
CreateSandboxRequest = { name, runtime: 'native'|'docker', snapshot?, envVars, labels,
                         public?, autoStopInterval?, autoDeleteInterval? }  // last three accepted, ignored locally
// No first-class scope: checkout identity is just a label the server stamps
// (Daytona-style). The daemon owns its whole working-dir root; scope fencing
// existed only for server-owned reaping, which no longer exists.
SandboxStreamEvent  snapshot `sandboxes_replaced` + `sandbox.created|started|stopped|deleted`; seq + Last-Event-ID replay

A2. New package agent/packages/agent-control-plane/ (@gamma/agent-control-plane; register in agent/package.json workspaces/typecheck/test; root script "control-plane": "bun run packages/agent-control-plane/src/main.ts"; entry in agent/process-compose.yaml so bun run agent brings it up).

src/main.ts              # bin entry: env config, startControlPlane(), signals
src/server.ts            # Hono + Bun.serve on 127.0.0.1:4025; exports startControlPlane(opts) for tests
src/config.ts            # ported defaults from packages/server/src/config/agent-sandbox.config.ts
                         #   (workingDirRoot, port range 4100–4999, sessionVolumesRoot — keep existing
                         #    default spelling `.gamma/harness-session-volmes`, agentRepoRoot from import.meta)
src/sandbox-manager.ts   # ported local-agent-sandbox.adapter.ts, de-NestJS'd: registry, atomic
                         #   sandbox.json, create-with-rollback, reconcile, reap, + startup REHYDRATION
src/sandbox.types.ts     # ported local-sandbox.types.ts (state machine, layout, env rewrites, TLS flag)
src/ports.ts             # ported local-sandbox-ports.ts (verbatim)
src/runtimes/native.ts   # ported local-native-sandbox.runtime.ts (detached spawn, pgid, 0600
                         #   runtime-env.json, ps-marker, kill escalation — verbatim behavior)
src/runtimes/docker.ts   # ported local-docker-sandbox.runtime.ts
src/events.ts            # SSE hub (~100 lines, modeled on agent-server/src/event-hub.ts)
src/redactor.ts          # COPY of startup-log-redactor.ts (server keeps its own for Daytona)

A3. HTTP API (all /v1, JSON, loopback; CORS for localhost dev origins so the web-ui can connect directly; errors { error: { code, message } }):

Route Notes
GET /v1/info identity/version/pid/repoRoot — probe + port-squat detection
POST /v1/sandboxes 201 → SandboxInfo; daemon owns full rollback before returning create_failed/preflight_failed; 409 on name conflict
GET /v1/sandboxes?label.k=v list with exact-match label filters (Daytona-style); reconcile runtime state on read
GET /v1/sandboxes/:idOrName get + reconcile; 404 not_found
DELETE /v1/sandboxes/:idOrName terminal teardown; idempotent 204 on missing
POST .../stop terminal for local v1 (parity with today)
POST .../refresh-activity keep-alive; bumps lastActivityAt, no state change, 204. Mirror of Daytona refreshActivity() (A7)
POST .../autostop { interval } minutes; mirror of setAutostopInterval; 0 disables
POST .../start 409 unsupported_lifecycle
POST .../resize compatibility no-op
GET .../preview-url?port=4000&ttl= { url: "http://localhost:<hostPort>" }; "signed" is an explicit no-op; only port 4000 valid
GET .../entrypoint-logs { output, stdout, stderr }, redacted daemon-side
GET /v1/events?label.k=v SSE lifecycle feed, ring buffer + Last-Event-ID, optional label filter

A4. Env provenance (required, easy to miss):

  • HOCUSPOCUS_HTTP_PORT/HOCUSPOCUS_URL (docker rewrite, local-docker-sandbox.runtime.ts:204-214) and OTEL_TRACES_SAMPLER (local-native-sandbox.runtime.ts:355-358) currently read server config/env → must travel in the create request envVars; daemon reads them only from the request.
  • Standalone sandboxes (web-ui/CLI origin, no GAMMA_AGENT_TOKEN/HARNESS_SESSION_ID): the daemon merges its own model-auth env keys into the child env when the request lacks them (same keys getRuntimeProviderEnv forwards; the daemon runs from agent/ where .env has them). Required for v1 — without it, web-ui- and prompt-created sandboxes can't call models and goals 2–3 don't function.
  • Daemon never logs request bodies (secrets transit loopback HTTP in create requests); log id/labels only.

A5. Persistence/reconciliation: children are detached/containers, so they survive daemon restarts. On startup the daemon rehydrates from workingDirRoot/<id>/sandbox.json (flat by sandbox id — no scope subdirs; the daemon owns its whole root), validates runtime handles (pid/pgid+ps-marker native, docker inspect docker), flips dead starting/startedstopped with errorReason. Reconcile-on-read for crashes + child.once('exit') publishes sandbox.stopped. The daemon is the only reaper — no external caller triggers cleanup; accumulation is bounded by A7 auto-stop + explicit deletes. SIGINT/SIGTERM does NOT kill children unless AGENT_CONTROL_PLANE_REAP_ON_EXIT=1 (tests).

A6. Tests (bun): ported sandbox.types/ports/redactor specs; server.spec.ts drives the full HTTP contract in-process with a fake runtime (CRUD, filters, error codes, idempotent delete, SSE replay, reap, rehydration from fixture trees); opt-in real-native smoke reusing AGENT_SANDBOX_RUN_NATIVE_RUNTIME_TESTS=1.

A7. Idle auto-stop + keep-alive — Daytona-identical, deliberately blind. Same model, same blindness: lastActivityAt + autoStopInterval + auto-stop loop, fed ONLY by client push. The daemon does not introspect the workload — no child polling, no running veto, no traffic sniffing, no presence tracking. Rationale: fidelity is the point of a simulator. A workload-aware local control plane masks broken keep-alive wiring that then only fails in staging — exactly the bug we validated (Daytona autoStopInterval: 10, zero refreshActivity callers, mid-generation auto-stops possible today). A blind local daemon surfaces that class of bug on every dev machine.

  • lastActivityAt per sandbox in the registry, persisted to sandbox.json (survives rehydration). Set on create; bumped ONLY by mutating control-plane ops + refresh-activity. Reads never count: GET /sandboxes/GET /:id/SSE watchers do NOT bump the clock — the web-ui polls the list on an interval, and read-as-activity would make auto-stop dead code. That's the complete keep-alive logic.
  • Keep-alive POST /v1/sandboxes/:id/refresh-activity = Daytona's refreshActivity: bumps lastActivityAt, no state change, 204. ControlPlaneClient.refreshActivity(id) wraps it (C1). autoStopInterval rides the create request; POST .../autostop {interval} mirrors setAutostopInterval.
  • Auto-stop loop: the reconcile interval terminal-stops any started sandbox where now - lastActivityAt > autoStopInterval (same path as POST .../stop: frees port, emits sandbox.stopped with errorReason: 'auto_stopped'). Never touches starting (grace window).
  • Drivers own their keep-alive (same responsibility model on both providers): main-app — the proxy activity relay (Phase B: adapter.refreshActivity(), one code path serving Daytona and local); web-ui — heartbeat while a session's SSE stream is open / on composer send; CLI — keep-alive while streaming, or --rm. A driver that forgets its keep-alive gets its sandbox auto-stopped — on both providers, loudly (auto_stopped in the web-ui row) — instead of only in staging.
  • Tab-closed background work auto-stops on both providers identically. The provider-agnostic fix, if it bites: the gamma server polls the child's GET /api/activity through the preview URL and translates running: true into adapter.refreshActivity() — lives in the gamma server, covers Daytona and local with the same code. Follow-up, not part of this stack. (For web-ui-origin sandboxes, auto-stop on tab close is the self-cleaning behavior a dev tool wants; durable state survives.)
  • Config: AGENT_CONTROL_PLANE_AUTOSTOP_MINUTES (default 15 = Daytona's default; 0 disables) — this is only the fallback for creates that don't specify autoStopInterval; harness creates pass AGENT_AUTO_STOP_MINUTES=10 explicitly on both providers, so main-app sandboxes behave identically everywhere. AGENT_CONTROL_PLANE_REAP_INTERVAL_MS (default 60s). server.spec.ts (fake clock): idle auto-stops, refreshActivity resets timer, reads don't reset it, busy-but-unpushed sandbox stops (Daytona-faithful).
  • No webhooks (deliberate). Daytona's webhook exists to fence the JWT when a restartable stopped container might come back holding its token; local stop is terminal, so that threat doesn't exist. Routing discovers stopped holders via reconcile-on-read (proxy getSandbox per request → 503 → resume), and revocation happens at resume-time lease takeover (revokeBySandboxId) — same server-visible outcomes, no daemon→server callback registration/secrets/retries. Follow-up shape if push is ever needed: daemon POSTs sandbox.state.updated to a configured URL, existing handleDaytonaWebhook-style handler consumes it.
  • Token side-benefit: an auto-stopped-then-recreated sandbox mints fresh, so idle gaps stop being a token-staleness source; only a sandbox kept continuously alive past the token TTL still needs the deferred refresh (unreachable locally under E1's 30d dev TTL).

Phase B — packages/server migration (clean cutover, no dual path)

  • New src/agent-sandbox/control-plane-api.types.ts — hand-mirrored wire types + error codes (workspaces can't share imports; header-comment both files as mirrors; adapter warns on /v1/info.version mismatch).
  • Rewritten local-agent-sandbox.adapter.tsimplements AgentSandboxAdapter as an HTTP client, structural sibling of daytona-agent-sandbox.adapter.ts:
    • getProviderTraits()/isCreateOnlyReadinessReason() stay local (server env), no daemon call.
    • CRUD/lifecycle/preview/logs → 1:1 route calls, ~5s timeouts so harness liveness checks stay snappy and fail closed (dead daemon = 503 through resolveRoutableProxyTarget, correct behavior).
    • Rollback: non-2xx create means daemon leaked nothing; existing service failure path (revoke JWT, best-effort delete) hits idempotent DELETE — safe double-cleanup.
    • No lifecycle hooks, no spawning, no reaps — guiding principle: the server treats the control plane exactly like Daytona. It's an external service assumed running; unreachable daemon → local readiness unavailable with a clear message (cd agent && bun run control-plane, or dev-cli), same failure mode as a missing DAYTONA_API_KEY. Sandboxes survive server restarts/redeploys by design: the lease is durable in Postgres and resumeSession already routes back to a live+routable holder (the Daytona path — local just never exercised it); the daemon's rehydrate/reconcile + A7 auto-stop are the cleanup authority. The server stamps a checkout-identity label on create (Daytona-style) so its own sandboxes are identifiable, but never bulk-manages them.
  • Daemon startup ownership (not the server's): dev-cli manages the control plane as a local service (packages/dev-cli wiring, alongside the existing infra it already runs with AGENT_SANDBOX_PROVIDER=local); bun run agent's process-compose runs it for agent-only workflows; bun run control-plane is the manual path. Config: AGENT_CONTROL_PLANE_URL (default http://127.0.0.1:4025).
  • New local-sandbox.errors.ts shim — LocalSandboxNotFoundError, LocalSandboxUnsupportedLifecycleError, LOCAL_SANDBOX_TARGET (still imported by agent-sandbox.service.ts).
  • agent-sandbox.service.ts — stays entirely (JWT mint, buildCreateParams, readiness/preview polling, metrics). Only change: buildCreateParams additionally injects HOCUSPOCUS_* + OTEL_TRACES_SAMPLER into envVars for the local provider (A4).
  • agent-sandbox.module.ts — drop LocalNativeSandboxRuntime/LocalDockerSandboxRuntime providers; keep provider/env validation.
  • config/agent-sandbox.config.ts — add control-plane keys; path/port-range keys migrate to the daemon config.
  • Delete after cutover: local-native-sandbox.runtime.ts, local-docker-sandbox.runtime.ts, local-sandbox-ports.ts, bulk of local-sandbox.types.ts. Update src/agent-sandbox/CLAUDE.md + packages/server/CLAUDE.md (both currently claim "no separate control-plane process").
  • harness-session/ — lease/liveness/proxy routing unchanged, plus one addition: the proxy activity relay. AgentSandboxAdapter gains refreshActivity(idOrName) (Daytona: sandbox.refreshActivity(); local: POST /v1/sandboxes/:id/refresh-activity — shape-identical by design, one relay serves both providers). The proxy service calls it (a) debounced per proxied request (skip if refreshed within ~interval/3), and (b) on a timer while a proxied SSE/streaming response is held open — the proxy is the one choke point that sees all main-app data-plane traffic, and the open-stream heartbeat is what keeps a single long generation alive on Daytona (preview traffic is invisible to Daytona's idle clock; validated against SDK docs). Fire-and-forget with error swallow — keep-alive must never fail a data-plane request. This also fixes the latent staging bug: AGENT_AUTO_STOP_MINUTES=10 with zero refreshActivity callers means mid-generation auto-stops are possible today.

Mandated opt-in suites keep their commands and scenarios; setup changes only:

  • local-native-sandbox.runtime.spec.ts / local-docker-sandbox.runtime.spec.ts — setup helper spawns a private daemon on an ephemeral port with test-scoped env (its own workingDirRoot/port range), health-polls /v1/info, builds the HTTP adapter against it; scenarios (create/preview/crash-reconcile/rollback/reap/teardown) run unchanged, now exercising the real seam. afterAll: list-by-test-label → delete each → kill daemon (Daytona-style cleanup, no scope reap).
  • local-harness-session.e2e.spec.ts + model-backed first-prompt — spec setup boots the same private daemon and sets AGENT_CONTROL_PLANE_URL; test-owned, not server-owned.
  • Default-run jest: rewrite local-agent-sandbox.adapter.spec.ts against a stubbed control-plane server; update agent-sandbox.module.spec.ts.

Phase C — Web-ui: control-plane driven, full drive

C1. ControlPlaneClient in agent/packages/agent-client/src/control-plane.ts (~150 lines, plain fetch + EventSource — don't reuse StreamTransport, its topic model is agent-server-specific): info/list/create/get/stop/delete, refreshActivity(id)/setAutostopInterval(id, minutes) (mirror the Daytona SDK, A7), entrypointLogsUrl, dataPlaneUrl(sandbox), waitForReady(id) (poll state + GET <preview>/api/info), watch(onEvent) with lastEventId reconnect. Tests against a Bun.serve fake control plane (kept in-repo as the executable spec of the contract) + the FakeEventSource pattern from transport.test.ts.

C2. De-singletonize the web-ui (mechanical, behavior-identical, merges first):

  • New web-ui-react/src/app/client-pool.ts — module-level, mirrors packages/client/src/modules/agent-chat/useRemoteSession.ts (proven per-base-URL client pattern): acquireClient(baseUrl) refcounted → client.stop() at 0 (with StrictMode-linger, copy the cancelled dance); clientForSession(sessionId) reverse index.
  • src/app/agent-client.ts shrinks to nullable legacy-server config (__AGENT_SERVER_URL__, drop main.tsx hard-throw) + pool re-exports.
  • Thread sessionId through the singleton's 7 importers: store/agent-client-hooks.ts (useAgentState/useAgentToolUpdate/optimistic append resolve store via clientForSession), app/useSessionState.ts (per-owning-client transport + workspace-version subscription), tools/renderers/renderer-utils.ts (relativePath(sessionId, path) — each sandbox has its own workspaceDir; ToolUpdatesContext already carries sessionId), export URLs in App.tsx.

C3. Sandbox registry + sidebar — new src/app/sandbox-registry.ts (vanilla zustand createStore):

  • Inputs: initial listSandboxes() + watch() events; per-started-sandbox mirror of the pooled client's sessions slice.
  • Merged view, control plane optional: probe both on boot. Control plane up → "Sandboxes" section (+ "Shared server" section if legacy also up); down → exactly today's behavior. No mode toggle.
  • Eager-light, lazy-heavy connections: eager client.start() per started sandbox (one EventSource per sandbox = separate origin, no per-origin limit stacking; powers live sidebar spinners/titles), capped (~15, newest-activity first, poll fallback beyond); heavy session:<id>:agent/:ui topics only for the active session via existing connectSession.
  • Rows render sandbox diagnostics from SandboxInfo alone: state dot, origin badge (harness/web-ui/cli), port, errorReason, entrypoint-logs link. Stopped sandboxes need no client.
  • ?session= stays the URL currency (sandbox ⇒ one session; sessionId IS the harness session id for main-app sandboxes). Boot resolution: registry → legacy list → stopped-sandbox row with "sandbox stopped" state (no auto-restart in v1, matches terminal-stop contract).

C4. Full drive: New session → createSandbox({labels:{origin:'web-ui'}}) → row appears starting via watch → waitForReadyacquireClientPOST /sessions {model} on the child → connectSessionupdateUrl. First prompt = normal composer send. Stop/delete actions per row; deleteAgentSessionAndConnectNext grows a sandbox branch. Export builds URLs from the session's base URL; import targets legacy server, or create-sandbox-then-import when control-plane-only.

C5. FilePanel per sandbox: active session's apiBaseUrl = ${dataPlaneUrl}/api (absolute; already threaded through FilesView/FilePreview/Gamma views); workspace-changed invalidation from the owning client (C2). Grep remaining literal '/api' under src/app/views/ + src/tools/ during implementation.

C6. Vite/URL wiring: keep /api proxy for legacy; add __CONTROL_PLANE_URL__ define (default http://127.0.0.1:4025); control plane + sandboxes reached directly (no proxy — avoids EventSource buffering; CORS covered).

Phase D — bun run prompt sandbox-per-run + dev stack

In gamma-agent/src/cli/prompt-client.ts (+ flags in commands/prompt.ts), extract testable resolvePromptTarget(env, fetchImpl):

  1. GAMMA_AGENT_SERVER_URL/AGENT_SERVER_URL set → existing direct path unchanged (env keeps meaning "that server"; preserves Docker recipe).
  2. Else probe GAMMA_AGENT_CONTROL_PLANE_URL127.0.0.1:4025; reachable → create new sandbox (origin:'cli'), waitForReady, then run the existing per-server flow (POST /sessions, SSE, prompt, exit on agent_settled) against the sandbox preview URL. Streaming code unchanged.
  3. Unreachable → today's 4001/4000 probe, with a one-line note of which mode ran.

Flags: --sandbox (require control plane), --no-sandbox, --rm (delete sandbox after settle/SIGINT). Default: leave the sandbox running, print Sandbox: <id> — inspect at <web-ui>?session=<id>, delete from sidebar or --rm. Sandbox-mode UI URL from GAMMA_AGENT_WEB_UI_URL (default http://localhost:4000), not the 4001→4000 rewrite.

Dev stack: bun run agent process-compose gains the control-plane process; startup output prints all three URLs (web-ui 4000 / shared server 4001 / control plane 4025).

Phase E — Agent token lifecycle (the "constantly expired token" bug)

The control-plane split (A–D) fixes the process-ownership half of this — server stops orphaning child processes — but not the token half; A5 rehydration actually makes the token staleness more visible. Both belong in this plan.

Symptom

Local, AGENT_SANDBOX_PROVIDER=local. A harness (main-app-origin) session that stays open keeps hitting the Gamma API with an expired agent JWT — Agent JWT verification failed: jwt expired → 401 on every /agent-tools/* call. Never self-corrects while the sandbox stays alive.

Root cause (three compounding gaps)

  1. Mint-once, no refresh. AgentSandboxService.mintSession (agent-sandbox.service.ts:666, no ttlSeconds → config default, was 6h) mints at create; injected as GAMMA_AGENT_TOKEN (:1772), read once at boot by local-sandbox-child.ts, cached in-process by gamma-fs. No refresh, no retry-on-401 anywhere (gamma-fs/src/auth.ts, gml-edit/src/lib/auth.ts).
  2. No local recycle. No idle reaper / interval / webhook locally. Prod forces fresh mints via Daytona AGENT_AUTO_STOP_MINUTES=10 + the sandbox.state.updated webhook's revokeBySandboxId. Locally only a server restart reaps (as orphan) — and A5 rehydration removes even that escape hatch.
  3. Reuse/resume is blind to token expiry. resumeSession reuses the holder when isLive + isSandboxRoutable (harness-session.service.ts:625-637); isSandboxRoutable probes the sandbox's unauthenticated /api/info — inbound health. The token is an outbound credential; an alive+routable sandbox with a dead token is classified healthy forever.

What A–D fix and don't fix

  • Fix — process ownership: server churn (nest start --watch skipping onApplicationShutdown) no longer orphans detached children; the daemon owns, rehydrates, reconciles. Structural fix for leaked/hanging processes, stands on its own.
  • Don't fix — token staleness: gaps 1 & 3 untouched. bun run prompt (D) is immune (fresh mint per run). Standalone web-ui/CLI sandboxes mostly immune (gamma-fs falls back to /dev/mint-agent-token, 30d dev token) but still cache-per-process. The bug lives on the harness (main-app) path, and A5 widens its window.

Fix (layered, cheapest first)

  • E1. Dev TTL — immediate mitigation. ALREADY IMPLEMENTED out of band (uncommitted in packages/server/src/config/oauth-provider.config.ts): DEFAULT_AGENT_TOKEN_TTL_SECONDS env-aware — 6h prod, 30d when APPLICATION_ENVIRONMENT === 'dev' (mirrors defaultAgentSandboxProvider()).
  • E2. Idle auto-stop + keep-alive (in scope — A7, client-push, Daytona-identical). Bounds live-process accumulation and how long a fixed token can linger; a reaped-then-recreated sandbox mints fresh. With E1's 30d dev TTL, the continuously-alive-past-TTL edge isn't reachable locally.
  • Deferred — refresh-on-401 in the agent (invalidate pinned token on Gamma 401, re-mint). Out of scope for this stack; tracked separately.

Verification additions

  • Token TTL: sandbox created under APPLICATION_ENVIRONMENT=dev carries ~30d exp; prod/unset still 6h.
  • Auto-stop: low AGENT_CONTROL_PLANE_AUTOSTOP_MINUTES, idle started sandbox → stopped with auto_stopped, port freed; refreshActivity resets timer; reads don't; a busy sandbox with no keep-alive caller stops on schedule (Daytona-faithful — this failing would have masked the staging bug).
  • Proxy activity relay: with a low autostop interval, a session held open through the gamma proxy (idle tab, then a long streaming generation) stays alive on both providers; closing the tab lets it auto-stop on schedule. Staging spot-check on Daytona once deployed.
  • Process ownership: kill -9 child → reconciled stopped; daemon restart with live children → rehydrated; server restart mid-generation → sandbox keeps running, session resumes to the same holder.

PR structure (graphite stack, two PRs)

PR 1 — agent/ control plane + clients (everything in the bun workspace; no packages/server changes):

  1. agent-protocol sandbox types + Bun.serve fake control plane fixture (the contract).
  2. agent-control-plane package: ported runtimes/types/ports/manager + HTTP server + bun tests + process-compose entry.
  3. Web-ui de-singletonization (behavior-identical; regression gate: control plane down + bun run agent behaves exactly as today).
  4. ControlPlaneClient + sandbox registry + sidebar + full drive + FilePanel + vite defines.
  5. bun run prompt sandbox mode + flags.

Note: during PR 1, the runtime code is temporarily duplicated (original still in packages/server, ported copy in agent/). That's the price of the split — acceptable because PR 2 deletes the originals and the stack merges together.

PR 2 (stacked on PR 1) — packages/server cutover: mirrored wire types, HTTP LocalAgentSandboxAdapter (no launcher — daemon is external infra), buildCreateParams env additions, module/config changes, dev-cli control-plane service wiring, jest spec rewrites, delete the moved runtime files, CLAUDE.md updates. Merge gated on all four mandated opt-in suites passing.

Verification

  • Per-package lint:fix (yarn) / bun run check + bun test (agent/).
  • All four mandated opt-in harness suites (root CLAUDE.md commands verbatim): native runtime, docker runtime, native harness-session e2e, model-backed first-prompt — required before PR 5 merges.
  • Manual end-to-end:
    1. Control plane down, bun run agent → web-ui identical to today.
    2. Web-ui: create two sandboxes, chat both in parallel, independent spinners, per-sandbox FilePanel workspaces.
    3. bun run prompt "..." twice → two cli-origin sandboxes appear live in sidebar; open via printed URL; delete from UI.
    4. Main app (AGENT_SANDBOX_PROVIDER=local): start a session from agents.tsx → sandbox appears in web-ui with harness badge; prompt it from the web-ui; main-app client still works through the gamma proxy.
    5. kill -9 a child → row flips stopped with reason; UI healthy. Restart the daemon while children run → rehydration keeps them.
    6. ?session= deep link into a sandbox session after reload; export JSONL.

Risks / open questions

  • Contract drift between the two hand-mirrored type files → keep contract small, info.version warn-on-mismatch, fake-server fixture as executable spec.
  • Survival across server restarts is a deliberate behavior change (today a server restart reaps everything, an artifact of the registry being in-memory): needs explicit coverage — restart the server mid-generation, session resumes to the same live sandbox; drift after a redeploy is mitigated by auto-stop + info.version warn, acceptable locally.
  • Daemon-not-running ergonomics: with no auto-spawn, yarn dev users need the daemon managed by dev-cli (wired in PR 2); until then the readiness error message must make the one-liner fix obvious.
  • Relay edges (identical on both providers by design): (a) background work with no client connected (tab closed, no proxied stream) gets no keep-alive and auto-stops — the follow-up if it bites is a gamma-server poll of the child's GET /api/activity (via preview URL) translated to adapter.refreshActivity(), provider-agnostic; (b) residual unknown whether Gamma's control-plane reads (getSignedPreviewUrl, get/list) reset Daytona's clock — the relay makes it moot for correctness, but a staging experiment (autoStopInterval: 1, hit only those endpoints) would settle it. Locally reads explicitly don't count, so if Daytona's do, that's a (benign) fidelity gap to note.
  • Env provenance audit during the runtime port: known leaks are OTEL_TRACES_SAMPLER, HOCUSPOCUS_*, and options.env: process.env in the docker rewrite (local-docker-sandbox.runtime.ts:214); anything else found moves into the request or daemon env.
  • N EventSources at dev scale: fine per-origin; cap + poll fallback from day one; if workspace topic proves chatty, add a startSessionsOnly() client option later.
  • Open (deferred, current answer in parens): restartable stop now that the daemon outlives the server (keep terminal for parity); one daemon per machine vs per checkout (single daemon, checkouts distinguished by labels, repoRoot in /v1/info); import UX in control-plane-only mode (create-then-import is acceptable for a diagnostics UI).

Appendix — Daytona compat table

Method-for-method

Daytona SDK (as called in daytona-agent-sandbox.adapter.ts) Local control plane /v1 Parity notes
daytona.list({ labels, limit: 100 }) GET /v1/sandboxes?label.k=v Exact-match label filter, both. Local reconciles runtime state on read. Reads never bump activity (explicit local rule; ambiguous in Daytona docs)
sandboxApi.createSandbox({ snapshot, target, envVars, labels, autoStopInterval, autoDeleteInterval }) POST /v1/sandboxes { name, runtime, snapshot?, envVars, labels, autoStopInterval, autoDeleteInterval } No first-class scope — checkout identity is a label. target/cpu/mem/disk meaningless locally; daemon owns full rollback; 409 on name conflict
daytona.get(idOrName) + sandbox.refreshData() GET /v1/sandboxes/:idOrName Reconcile-on-read = refreshData()
sandbox.delete(timeout) DELETE /v1/sandboxes/:idOrName Local idempotent 204 on missing
sandbox.stop(timeout) POST /v1/sandboxes/:id/stop Divergence: Daytona restartable; local terminal (v1). Auto-stop uses this same path (errorReason: 'auto_stopped')
sandbox.start(timeout) POST /v1/sandboxes/:id/start Divergence: 409 unsupported_lifecycle locally
sandbox.resize(resources, timeout) POST /v1/sandboxes/:id/resize Local no-op returning current info
sandbox.getSignedPreviewUrl(port, ttl) GET /v1/sandboxes/:id/preview-url?port=4000&ttl= Local: unsigned http://localhost:<port>, ttl ignored, only port 4000 valid
sandbox.process.getEntrypointLogs() GET /v1/sandboxes/:id/entrypoint-logs Local redacts daemon-side
sandbox.refreshActivity() POST /v1/sandboxes/:id/refresh-activity Called via the proxy activity relay through AgentSandboxAdapter.refreshActivity() — one code path serves both providers. Load-bearing on Daytona; presence-override locally
sandbox.setAutostopInterval(minutes) POST /v1/sandboxes/:id/autostop { interval } 0 disables, both. Not called by server today; available to web-ui/CLI locally

System-level differences (deliberate)

Concern Daytona Local control plane
Identity Daytona-minted id + separate name id === name === tokenSandboxId
Grouping Labels Labels only (scope demoted to a label; no scope routes)
States Full Daytona enum incl. archiving/archived/error/build_failed starting → started → stopped → destroyed
Idle detection Blind to workload — client-push refreshActivity only; preview traffic explicitly excluded (validated against SDK docs) Identical by design: blind, client-push only; reads explicitly don't count. Fidelity over cleverness — a workload-aware local daemon would mask broken keep-alive wiring until staging
Auto-stop default 15 min when create doesn't specify; harness creates pass 10 Same: 15 when create doesn't specify; harness creates pass the same 10 (one buildCreateParams, both providers)
Auto-delete / archive autoDeleteInterval (-1 = never), autoArchiveInterval → object storage Accepted, ignored — delete stays explicit
Stop consequences Filesystem preserved, restartable, webhook fires → server revokes JWT Terminal; durable state on session volume; resume = fresh sandbox + fresh mint
State-change push Svix webhooks → server (fences JWT because stopped Daytona containers are restartable with their old token) None to server, by design: local stop is terminal so the fencing threat doesn't exist; reconcile-on-read + resume-time revocation give the same outcomes. SSE /v1/events → web-ui only
Lifecycle coupling to server None (external service) None (same principle) — no spawn, no reaps, no hooks
Auth DAYTONA_API_KEY None; loopback bind + /v1/info identity check, dev-only
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment