Skip to content

feat(sandbox): unify a workspace's agent sandbox with the ACP session - #141

Merged
DIodide merged 2 commits into
stagingfrom
feat/workspace-agent-sandbox-unify
Jun 21, 2026
Merged

feat(sandbox): unify a workspace's agent sandbox with the ACP session#141
DIodide merged 2 commits into
stagingfrom
feat/workspace-agent-sandbox-unify

Conversation

@DIodide

@DIodide DIodide commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

When a harness that runs an ACP agent (Claude Code / Codex / Cursor) is used in a workspace, the agent now runs inside the workspace's sandbox instead of spinning up a separate throwaway session-owned box. That box is named {Agent} · {Harness} (e.g. "Claude Code · My Harness") and is reused across the workspace's chats — and shared with code-execution. No more orphan Claude Code sandboxes disconnected from the workspace.

Behavior (per the design decisions)

  • Per-workspace keyingworkspace.sandboxId is the one box for the workspace.
  • Respect explicit — if a sandbox is explicitly assigned (harness or workspace), it's used.
  • Auto-assign for ACP harnesses — assigning an ACP harness to a workspace adopts its sandbox; if it has none yet, the gateway creates one on the first session, names + links it (harness and workspace), and reuses it thereafter.

How it works

  1. Frontend already threads workspace_id into the harness config on both /chat and /workspacesno UI change needed; the linked sandbox name renders reactively.
  2. Gateway _resolve_sandbox_plan decides: explicit harness sandbox → attach; workspace already has a box → attach; ACP harness in a workspace with none → create a persistent box.
  3. A freshly-created persistent box is owned-by-default (reclaimed on teardown) and only flips to persistent once Convex confirms the link — a fail-safe so any failure (conn error, provision timeout, lost race) reclaims it instead of orphaning it.
  4. createInternal links the workspace and returns null when it lost a create race (the workspace already links one) — relying on Convex's serializable mutations — so the losing session reclaims its duplicate box.

Changed

Convex: workspaces.ts (auto-adopt + resolveSandboxInternal), sandboxes.ts (createInternal workspace-link + lost-race null + agent-gated sandboxEnabled; removeByDaytonaId clears workspace/harness links; per-user cap 5 → 20), harnesses.ts (resolveForCollab carries workspaceId).
FastAPI: models.py, services/convex.py (resolve_workspace_sandbox, create_sandbox_record(workspace_id=…)), daytona_runtime.py (persist shaping + owned-by-default), session_manager.py (_resolve_sandbox_plan, _register_workspace_sandbox).
Frontend: cap mirror bump only.

Verification

Ran a 3-dimension adversarial multi-agent review. It found and I fixed:

  • Blocker — a concurrency race where two new chats in a fresh workspace each created a box, leaving one a permanent orphan + diverging harness.sandboxId. Fixed via the lost-race null + owned-by-default reclaim (Convex serializability guarantees exactly one winner).
  • Major — the persistent box's first run skipped the gai.conf IPv6→IPv4 write (native tools could hang on dual-stack egress). Fixed for free by owned-by-default (the if owns_sandbox gate now covers it).
  • Major — sandbox-cap cliff (persistent boxes no longer torn down). Bumped 5 → 20 + reclaimed orphans.

Tests: new test_workspace_sandbox_unify.py (8) + Convex sandboxes.test.ts / workspaces.test.ts additions. Full suites: Convex 191, FastAPI 320. tsc / biome / ruff clean on changed files.

Notes for review

  • Cap bump to 20 is a resource-policy call — persistent boxes auto-stop when idle (low cost), and the model is ~1 box per ACP workspace. Tune MAX_SANDBOXES_PER_USER (mirrored in 3 files) if you want a different ceiling.
  • Harness reuse across workspaces shares one box (the box lives on harness.sandboxId, surfaced as each workspace's sandbox) — consistent with the naming + auto-assign decision, but per-workspace file isolation isn't guaranteed when the same harness is used in multiple workspaces.
  • Worth a manual smoke test on staging: new workspace + Claude Code harness → confirm one named box is created, reused on the next message, and shared with code-exec.

When a harness that runs an ACP agent (Claude Code/Codex/Cursor) is used in
a workspace, the agent now runs INSIDE the workspace's sandbox instead of a
separate throwaway session-owned one, and that box is named "{Agent} ·
{Harness}". No more orphan Claude Code sandboxes disconnected from the
workspace.

Per-workspace keying (workspace.sandboxId is the one box). Respect an
explicitly-assigned sandbox; otherwise an ACP harness assigned to a workspace
auto-adopts its sandbox. First session in a fresh workspace creates a
PERSISTENT box (owned-by-default until Convex confirms the link, then it
survives teardown), records it in Convex linked to the harness + workspace,
and reuses it thereafter.

Convex:
- workspaces.ts: create/update auto-adopt an ACP/sandbox harness's sandbox
  (harnessSandboxToAdopt); internalQuery resolveSandboxInternal.
- sandboxes.ts: createInternal gained workspaceId (links workspace, agent-gated
  sandboxEnabled) and returns null when it LOST a create race (workspace
  already links one) so the gateway reclaims the duplicate — relies on Convex
  serializable mutations. removeByDaytonaId now clears workspace + harness
  links. Per-user cap 5 -> 20 (each workspace keeps one auto-stopped box).
- harnesses.ts: resolveForCollab carries workspaceId (collab turns unify too).

FastAPI:
- models.py: harness_config_from_resolved carries workspace_id.
- services/convex.py: resolve_workspace_sandbox(); create_sandbox_record gained
  workspace_id and returns None on a declined (lost-race) create.
- daytona_runtime.py: provision_agent_sandbox(persist=True) shapes the box as a
  persistent home (cwd + label + gai.conf) but returns owns_sandbox=True — a
  fail-safe so any failure between create and link reclaims it (no orphan).
- session_manager.py: _resolve_sandbox_plan picks attach-vs-create;
  _register_workspace_sandbox links the box and flips it to persistent only on
  a confirmed link (a None/lost-race or any failure leaves it owned).

Frontend already threads workspace_id on both /chat and /workspaces; the linked
sandbox name renders reactively — no UI change needed (cap mirror bumped).

Adversarially verified (multi-agent): fixed a concurrency-race orphan + harness
divergence, missing gai.conf/IPv6 on the persistent box's first run, and the
sandbox-cap cliff. Tests: Convex 191, FastAPI 320; tsc/biome/ruff clean.
Comment on lines +1289 to +1294
if not adopted:
if create_persistent:
# Just created the workspace's unified box (owned-by-default, so
# any failure reclaims it). Link it and, on success, flip it to
# persistent. Handles a lost race / Convex failure internally.
await self._register_workspace_sandbox(session, agent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: owned runtime from attempt 1 is orphaned when create_persistent=True and conn.start() raises a transient error

_register_workspace_sandbox runs here — after conn.start() (line 1278) and conn.initialize() (line 1280). When a transient OSError (the "freshly created sandboxes occasionally reset the first toolbox connection" case) hits conn.start(), the retry loop in _provision_with_retry runs again without having written a Convex link for Box #1.

The retry cleanup clears session.connection but not session.runtime. On the second _provision_once call:

  1. _resolve_sandbox_plan returns (None, True) again — no Convex link was ever written for Box Initial project setup #1.
  2. provision_agent_sandbox creates Box Feat: Setup Project Environment + Base Codebase #2 and overwrites session.runtime (line 1262).
  3. Box Initial project setup #1 — still owns_sandbox=True, no Convex record, no Python reference — is permanently orphaned. The owned-by-default fail-safe cannot reach it because teardown only follows session.runtime, which now points to Box Feat: Setup Project Environment + Base Codebase #2.

Fix: in _provision_with_retry, before sleeping and retrying, tear down any owned runtime whose link was never confirmed:

# Reclaim any owned runtime whose Convex link was never written —
# registration runs after conn.start(), so a transient connection
# failure leaves an unlinked box that must be destroyed before retry.
if session.runtime is not None and session.runtime.owns_sandbox:
    with contextlib.suppress(Exception):
        await asyncio.to_thread(session.runtime.teardown)
    session.runtime = None

Add this after session.connection = None in _provision_with_retry, before await asyncio.sleep.

Related: _provision_with_retry retry cleanup

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 2431d57. _provision_with_retry now reclaims an owned, unlinked box (via _destroy_runtime) before retrying; attached boxes are left in place for re-attach. Added a regression test (test_owned_box_from_failed_attempt_is_destroyed_before_retry). This also closes the latent pre-existing leak on the throwaway path.

…fore retry

Sandbox registration runs only after conn.start()/initialize, so a transient
connection failure there left the freshly-created, owned, unlinked box behind:
the retry provisioned a new one and overwrote session.runtime, orphaning the
first (owned-by-default could not reach it — teardown follows session.runtime).
Destroy an owned runtime before retrying; attached boxes are left for re-attach.

Flagged by code review on the persistent-box path; also fixes the latent
pre-existing leak on the throwaway path.
@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@DIodide
DIodide merged commit 188260c into staging Jun 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant