Async Migration Plan
Async Migration Plan — Native Libraries, Step by Step
Context
The previous PRs used asyncio.to_thread to wrap blocking calls. The code review flagged:
- Thread-safety bugs (DB sessions passed to worker threads)
- Unbounded queue in custom stream generator
- Daemon thread-per-stream with no cap
- StreamingResponse with sync generator already runs in Starlette's thread pool (~40 tokens) — the custom thread+queue was unnecessary overhead
- Thread pool saturation risk under load
New direction: Replace blocking libraries with native async equivalents. Use to_thread only as a temporary bridge for code that cannot be converted yet. Measure every step.
---
Step 0: Revert thread-based changes
Branch: fix/wait-for-task-start-thread (5 commits)
Revert these changes that introduced asyncio.to_thread wrappers and the custom stream generator:
| File | What to revert |
|------|----------------|
| conversation_routing.py | Remove redis_stream_generator_async, revert start_celery_task_and_stream to sync, remove asyncio.to_thread around ensure_unique_run_id and wait_for_task_start |
| conversations_router.py | Remove asyncio.to_thread wrappers around session_service, redis_manager, ensure_unique_run_id; restore local_mode / request / UA detection in create_conversation |
| conversation_service.py | Remove asyncio.to_thread around history_manager., session_service., redis_manager.*, celery_app.control.revoke |
| redis_streaming.py | Revert stop_event parameter, revert xread block from 1000ms back to 5000ms. Keep require_running in wait_for_task_start (that's a correctness fix, not a threading change) |
| api/router.py | Remove asyncio.to_thread around ensure_unique_run_id |
| tunnel_router.py | Remove asyncio.to_thread wrappers |
| tunnel_service.py | Revert scan_iter and TTL changes only if they were part of the thread PR. Keep if they were independent fixes |
| socket_service.py | Remove close() method if it was added as part of the thread PR |
| main.py | Remove shutdown handler if it was part of the thread PR |
| github_service.py | Remove asyncio.to_thread around redis.get/redis.setex in get_project_structure_async |
Keep from the thread PR:
- wait_for_task_start(require_running=True) — correctness fix, not a threading change
- Timeout additions (timeout=30 on requests calls) — safety fix
Keep from fix/sync-timeouts-only:
- All timeout additions (auth_service, github_service, parse_webhook_helper)
- parsing_controller._query_parsing_status_sync with asyncio.to_thread — acceptable bridge until async DB migration
---
Step 1: AsyncRedisStreamManager for FastAPI
What: Create redis.asyncio client alongside the existing sync client. Add async methods for all operations called from FastAPI.
Files to change:
- redis_streaming.py — add AsyncRedisStreamManager class using redis.asyncio
- async def get_task_status()
- async def set_task_status()
- async def set_task_id()
- async def get_task_id()
- async def publish_event()
- async def set_cancellation()
- async def get_stream_snapshot()
- async def clear_session()
- async def wait_for_task_start() — use asyncio.sleep instead of time.sleep
- conversation_routing.py — use AsyncRedisStreamManager in start_celery_task_and_stream (make it async def), start_celery_task_and_wait
- ensure_unique_run_id → async_ensure_unique_run_id using async Redis
- Leave sync redis_stream_generator as-is (Starlette thread pool handles it)
- session_service.py — add AsyncSessionService using AsyncRedisStreamManager. Use await async_redis.scan(...) instead of keys()
- conversations_router.py — switch to async versions
- api/router.py — switch to async versions
- conversation_service.py — stop_generation uses async Redis methods
Keep unchanged:
- Sync RedisStreamManager — still used by Celery tasks (agent_tasks.py, parsing_tasks.py)
- Sync redis_stream_generator — Starlette runs it in thread pool
Test script: scripts/benchmark_redis_async.py
Measures:
1. Time to complete N concurrent POST /message requests (streaming)
2. Time to complete N concurrent GET /active-session requests
3. Time to complete N concurrent GET /task-status requests
4. Compare: before (sync Redis on event loop) vs after (native async Redis)
5. Report: p50, p95, p99 latency; requests/sec; event loop blocked time (via middleware)PR: feat/async-redis-stream-manager
---
Step 2: AsyncSession for hot-path DB services
What: Migrate services called from FastAPI to use AsyncSession.
2a: UsageService
Why first: Called on every create_conversation and post_message (check_usage_limit). Highest frequency.
- usage_service.py — replace SessionLocal() + session.query() with AsyncSessionLocal + await async_session.execute(select(...))
- Inject AsyncSession from FastAPI dependency
Test script: scripts/benchmark_usage_check.py
Measures:
1. N concurrent POST /conversations (each triggers check_usage_limit)
2. Latency distribution before/afterPR: feat/async-usage-service
2b: ChatHistoryService (dual-path) ✅
Why: Used from both FastAPI (store_message, stop_generation) and Celery (agent_tasks). Cannot just swap to async.
- Create AsyncChatHistoryService with AsyncSession:
- async def get_session_history()
- add_message_chunk() (in-memory buffer only)
- async def flush_message_buffer()
- async def save_partial_ai_message()
- ConversationService.create() — accepts optional async_db; when provided, builds AsyncChatHistoryService and uses it on FastAPI path
- FastAPI path uses async variant; Celery path uses existing sync ChatHistoryService
Test script: scripts/benchmark_chat_flow.py
Measures:
1. N concurrent POST /message (streaming) — end-to-end including DB writes
2. N concurrent POST /stop — stop_generation with DB save
3. Latency distributionPR: feat/async-chat-history-service
2c: ShareChatService, AccessService, UserService ✅
- AsyncShareChatService: New class in access_service.py using AsyncSession; share_chat, get_shared_emails, remove_access (select/update/commit).
- conversations_router: Share/access endpoints use get_async_db and AsyncShareChatService(async_db).
- AsyncUserService: New class in user_service.py; async get_user_by_uid, get_user_id_by_email, get_user_by_email, get_user_ids_by_emails, create_user, update_last_login. Sync UserService retained for Celery/main.
- api/router: get_api_key_user uses AsyncUserService(async_db).get_user_by_uid. Auth routes (2d) remain on sync UserService for now.
Test script: scripts/benchmark_share_access.py
Measures:
1. N concurrent POST /share
2. N concurrent GET /shared-emails
3. N concurrent DELETE /accessPR: feat/async-share-access-user-services
2d: Auth routes (signup, sso_login, provider endpoints) ✅
- auth_router.py: inject get_async_db, use AsyncUserService(async_db) for user lookups (get_user_by_uid, get_user_by_email, update_last_login) in signup, sso_login, get_my_account
- UnifiedAuthService remains sync for this PR; full async migration can follow
Test script: scripts/benchmark_auth.py
Measures:
1. N concurrent POST /signup
2. N concurrent POST /sso/login
3. N concurrent GET /providers/me
4. Latency distributionPR: feat/async-auth-db
2e: GithubService DB queries ✅
- get_combined_user_repos(user_id, async_session=...) and get_repos_for_user(user_id, async_session=...) accept optional AsyncSession; when provided use select() + await session.execute()
- code_provider_controller.get_user_repos and github_router pass async_db into get_combined_user_repos
PR: feat/async-github-db (included in feat/async-share-access-user-services)
---
Step 3: Async Redis for non-streaming services ✅
What: Add redis.asyncio to services that do simple Redis get/set from FastAPI.
| Service | Change |
|---------|--------|
| tunnel_service.py | ✅ Async client + get_workspace_tunnel_record_async, set_workspace_tunnel_record_async, list_user_tunnels_async. Sync list_user_tunnels uses scan_iter not keys. Workspace record set uses TTL (WORKSPACE_TUNNEL_RECORD_TTL). |
| tunnel_router.py | ✅ Both routes call await tunnel_service.get_workspace_tunnel_record_async(workspace_id). |
| github_service.py | ✅ Lazy shared _get_async_redis_cache(); get_project_structure_async uses async Redis for cache get/setex when available. |
| branch_cache.py | ✅ Optional _async_redis_client; get_branches_async for FastAPI path. Controller get_branch_list uses await branch_cache.get_branches_async. |
Test script: scripts/benchmark_tunnel_github.py
Measures:
1. N concurrent GET /tunnel/workspace/{id}
2. N concurrent GET /tunnel/workspace/{id}/socket-status
3. N concurrent GET /github/user-repos (triggers branch cache / repo listing)
4. Latency distribution (p50, p95, p99); req/s
Usage: WORKSPACE_ID=<16-hex> AUTH_HEADER="Bearer <token>" CONCURRENT=5 ROUNDS=2 uv run python scripts/benchmark_tunnel_github.pyPR: feat/async-redis-tunnel-github
---
Step 4: Async HTTP clients ✅
What: Replace requests with httpx where called from FastAPI; offload sync-only SDKs to threads.
| Location | Change |
|----------|--------|
| auth_service.py | Sync login uses httpx.Client; added login_async with httpx.AsyncClient (timeout 10s/30s). Route calls await auth_handler.login_async. |
| auth_router.py | send_slack_message uses httpx.AsyncClient().post with timeout. |
| parse_webhook_helper.py | send_slack_notification uses httpx.AsyncClient().post with timeout. |
| linear_client.py | Sync execute_query uses httpx.Client (timeout 10s/30s); added execute_query_async, get_issue_async, update_issue_async, comment_create_async. Linear tools use async methods. |
| email_helper.py | resend.Emails.send wrapped in await asyncio.to_thread(resend.Emails.send, params) in both send_email and send_parsing_failure_alert. |
| posthog_helper.py | send_event: when event loop is running, uses loop.run_in_executor(None, _capture_sync) (fire-and-forget); otherwise runs _capture_sync inline. |
Test script: scripts/benchmark_login.py (optional; not added in this PR)
PR: Combined with Step 3 in feat/async-redis-tunnel-github
---
Step 5: SearchService async DB
- search_service.py — migrate search_codebase to AsyncSession
- knowledge_graph_router.py — use async session for project verification query
PR: feat/async-search-service
---
Test infrastructure
Shared benchmark harness: scripts/benchmark_harness.py
"""
Usage: python scripts/benchmark_harness.py --target <script> --concurrency 10,20,50 --rounds 3For each concurrency level:
1. Warm up (5 requests)
2. Run N concurrent requests × rounds
3. Report: min, p50, p95, p99, max latency; total wall time; requests/sec
4. Compare with baseline file if provided (--baseline results.json)
"""
Event loop monitor middleware: scripts/eventloop_monitor.py
"""
FastAPI middleware that measures event loop blocking.
Logs a warning when any request handler blocks the event loop for > 100ms.
Reports: blocked_time_ms, endpoint, method.
Run with: add middleware to app in development/staging.
"""Per-step test scripts
Each step has a dedicated script (listed above) that:
1. Hits the actual API endpoints of a running local server
2. Uses httpx.AsyncClient with asyncio.gather for concurrency
3. Requires CONVERSATION_ID, AUTH_TOKEN env vars (real auth against local)
4. Outputs JSON results for before/after comparison
5. Validates: latency improved, no errors, functional correctness (response shape)
---
Execution order and PR strategy
| Order | PR | Risk | Impact |
|-------|-----|------|--------|
| 0 | Revert thread wrappers | Low (restores known-good state) | Removes thread-safety bugs |
| 1 | AsyncRedisStreamManager | Medium (new component) | Unblocks streaming hot path |
| 2a | Async UsageService | Low (isolated) | Unblocks every message send |
| 2b | Async ChatHistoryService | Medium (dual-path) | Unblocks message DB writes |
| 2c | Async Share/Access/User | Low (simple CRUD) | Unblocks sharing endpoints |
| 2d | Async Auth DB | High (large surface) | Unblocks login/signup |
| 2e | Async GitHub DB | Low (small) | Unblocks repo listing |
| 3 | Async Redis (tunnel/github cache) | Low | Unblocks tunnel + cache |
| 4 | Async HTTP clients | Low | Unblocks login, notifications |
| 5 | Async SearchService | Low | Unblocks search |
Each PR:
1. Run benchmark before (on main or previous step)
2. Merge the PR
3. Run benchmark after
4. Record results in docs/async-migration-results.md
5. If regression detected, revert before proceeding
---
What we do NOT change
- Celery tasks — sync by design. No changes.
- Neo4j — all usage is in Celery agent tasks via to_thread(self.run). Fine as-is.
- File I/O / subprocess — all in Celery parsing tasks. Fine.
- Sync redis_stream_generator — Starlette's thread pool handles sync iterators in StreamingResponse. No custom thread/queue.
- to_thread inside Celery's event loop (tool arun methods) — Celery creates its own loop per task; to_thread there is fine.
---
Dependencies to add
Already in the project:
redis # has redis.asyncio built-in
sqlalchemy # has AsyncSession built-in
httpx # already used in some placesNo new packages needed — all async support is in existing deps.
---
Success criteria
Per step:
- p95 latency for affected endpoints improves or stays flat
- No new errors in logs
- Event loop blocked time (via middleware) decreases
- Thread count (via /proc or metrics) decreases or stays flat
Overall:
- All FastAPI async routes have zero sync I/O on the event loop
- Thread pool usage is only from Starlette's managed pool for StreamingResponse sync generators
- Measured ~30-40% more requests/instance capacity (Duolingo benchmark)
---
Sandbox Core Setup
Sandbox Core Setup
Context
Potpie agents need an isolated place to inspect and change user repositories:
clone once, reuse the repo later, create worktrees for new work, run commands,
edit files, run tests, and resume the same state in a later agent session.
The important distinction is that "sandbox" is not one thing:
- A repo cache is the durable git mirror. It should avoid recloning.
- A workspace is the durable working tree or worktree where the agent sees
files and can make changes.
- A runtime is the compute environment that runs commands against a
workspace. It may be stopped, recreated, or moved without deleting the
workspace.
The hexagonal sandbox module now lives at app/src/sandbox/sandbox/ (domain
ports, application service, API client, outbound adapters). Local mode wiresLocalGitWorkspaceProvider + LocalRepoCacheProvider directly; parsing keeps
using app/modules/repo_manager/repo_manager.py for its own clone/eviction
needs, but sandbox traffic no longer routes through it.
This design keeps the repo/worktree lifecycle separate from the execution
backend. The application layer asks for a workspace, then attaches a runtime to
it.
---
Goals
- Clone a user repo once and reuse it across agent sessions.
- Keep durable agent work in a workspace/worktree, especially for feature work.
- Let multiple execution backends run against the same workspace abstraction:
local container, Docker, Daytona, E2B, or future providers.
- Allow the compute runtime to be hibernated or destroyed without losing the
repo cache or workspace state.
- Support explicit eviction policies for runtimes, workspaces, and repo caches.
- Keep application code backend-agnostic without hiding important persistence
semantics.
Non-goals
- Building a human developer environment like VS Code Remote, Coder, or devpod.
- Making every backend expose identical networking, region, quota, snapshot, or
preview behavior.
- Replacing the existing read-only gvisor_runner.py path immediately.
- Treating snapshots as the only persistence mechanism. Git worktrees and
volumes are first-class persistence.
---
Core Model
RepoCache
A RepoCache is a durable local or remote git mirror for a repository.
In local mode this maps naturally to the existing bare repo:
.repos/<owner>/<repo>/.bare/Responsibilities:
- Normalize repo identity.
- Clone/fetch using the existing auth chain.
- Store metadata needed for eviction and debugging.
- Never hold uncommitted agent edits.
Key rule: repo cache is shared infrastructure. It is not the agent's mutable
working area.
Workspace
A Workspace is a durable checkout/worktree created from a repo cache.
Typical examples:
- Read-only analysis workspace for project_id + base_ref.
- Edit workspace for project_id + conversation_id.
- Task workspace for project_id + task_id.
In local mode this maps to a git worktree:
.repos/<owner>/<repo>/worktrees/<user>_<conversation>_<branch>/Responsibilities:
- Provide a stable filesystem root for an agent session.
- Preserve uncommitted edits, generated files, dependency installs, test
artifacts, and branch state until explicitly cleaned or evicted.
- Track metadata: owner user, project, repo, base ref, branch, dirty state,
last used time, pinned status, and runtime attachment.
Key rule: persistent agent work belongs to a workspace, not to the runtime.
SandboxRuntime
A SandboxRuntime is the compute environment that executes commands against a
workspace.
Examples:
- Local Docker container with workspace mounted.
- Daytona workspace runtime.
- E2B microVM.
- Local read-only gVisor subprocess path for analysis only.
Responsibilities:
- Start, stop, destroy, and report state.
- Execute commands.
- Stream long-running command output.
- Expose optional backend capabilities such as preview URLs or snapshots.
Key rule: destroying a runtime must not delete a workspace unless the caller
explicitly asks for workspace cleanup.
---
Current State (Implemented)
The hexagonal layout described above is in place. References point at code
that is shipped today.
Domain (app/src/sandbox/sandbox/domain/)
- models.py — Workspace, WorkspaceRequest, RepoIdentity, RepoCache
(declared but not yet wired through), Runtime, RuntimeSpec,
ExecRequest, ExecResult, Mount, WorkspaceMode (ANALYSIS/EDIT/
TASK).
- errors.py — WorkspaceError family (WorkspaceNotFound,
RepoCacheUnavailable, RepoAuthFailed, InvalidWorkspacePath, …) and
RuntimeErrorBase family (RuntimeUnavailable, RuntimeTimeout, …).
- ports/workspaces.py, ports/runtimes.py, ports/stores.py,
ports/locks.py — the four ports.
Application (app/src/sandbox/sandbox/application/services/sandbox_service.py)
- SandboxService owns get_or_create_workspace,
get_or_create_runtime, exec, hibernate_runtime,
destroy_runtime, destroy_workspace. Persists workspaces and runtimes
via the store, takes per-workspace locks for mutating commands.
API (app/src/sandbox/sandbox/api/client.py)
- SandboxClient is the public façade. Helpers: read_file,
write_file, list_dir, search, status, diff, commit, push.
Local-fs fast paths when handle.local_path is not None; exec-based
fallbacks otherwise.
Bootstrap (app/src/sandbox/sandbox/bootstrap/)
- settings.py reads SANDBOX_WORKSPACE_PROVIDER /
SANDBOX_RUNTIME_PROVIDER / SANDBOX_REPOS_BASE_PATH /
SANDBOX_METADATA_PATH.
- container.py — build_sandbox_container selects providers and store.
Adapters
- adapters/outbound/local/git_workspace.py — LocalGitWorkspaceProvider.
Bare-repo + worktree creation under .repos/, with
<user>_<scope>_<branch> worktree paths to isolate per-conversation
edits. Currently not used in production — the bridge below overrides
the wiring.
- adapters/outbound/local/subprocess_runtime.py —
LocalSubprocessRuntimeProvider for local exec.
- adapters/outbound/daytona/provider.py — DaytonaWorkspaceProvider and
DaytonaRuntimeProvider. One Daytona sandbox per (user, project),
branch-named worktrees inside it.
- adapters/outbound/docker/runtime.py — DockerRuntimeProvider.
- adapters/outbound/file/json_store.py — JsonSandboxStore (durable
metadata; default for local mode).
- adapters/outbound/memory/store.py, memory/locks.py — in-memory
fallbacks.
Agent tool surface (app/modules/intelligence/tools/sandbox/)
- client.py — process-wide SandboxClient accessor (get_sandbox_client)
and resolve_workspace(...) helper.
- context.py — contextvars (user_id, conversation_id, branch,
auth_token) the agent harness sets at run start.
- tools.py exports create_sandbox_tools() returning four tools:
sandbox_text_editor, sandbox_shell, sandbox_search, sandbox_git.
- tool_functions.py — each tool calls _resolve(project_id, mode=...)
to get a WorkspaceHandle per call.
---
Known Gaps (post-revamp)
Findings from the architectural audit; tracked by the Implementation
Roadmap below.
1. Two parallel local providers, one is dead code. (resolved)
LocalGitWorkspaceProvider is the only local sandbox provider; the
RepoManagerWorkspaceProvider bridge has been removed. Parsing still
uses RepoManager for its own clone/eviction; sandbox traffic does not.
2. Dual unsynchronized persistence. (resolved for sandbox traffic)
With the bridge gone, sandbox state lives only in JsonSandboxStore.
Parsing's RepoManager metadata at
.repos/.meta/<owner>/<repo>/branch__commit.json is independent and
no longer needs to reconcile.
3. Eviction lives outside the sandbox application layer.
Volume tracking and tiered eviction (worktrees first at 80%, full
repos at 90%) are inside RepoManager. The sandbox module has no
EvictionPolicy port and no eviction logic of its own.
4. Parsing bypasses the sandbox abstraction.
parsing_service.py:298 calls parse_helper.clone_or_copy_repository
which goes directly to RepoManager. There is a feature-flagged
SandboxClient path (SANDBOX_PARSING_ENABLED), but it is not the
default and does not persist a Workspace past the call. After
update_project_status(..., READY) at parsing_service.py:575 no
workspace record exists in the sandbox store.
5. No first-class RepoCache entity. RepoCache is declared in
models.py:115 but Workspace.repo_cache_id is always None. There
is no RepoCacheProvider port; the bare-repo concept is implicit
inside each adapter.
6. WorkspaceMode overloads four concerns: read-only vs writable,
branch creation, sharing, and keying. Branch-creation logic is
duplicated across LocalGitWorkspaceProvider._create_workspace_sync
and daytona/provider.py:_ensure_worktree. Capabilities should be
explicit on the workspace, derived from mode at construction.
7. Daytona adapter leaks SDK types. Any-typed sandbox handles
(_client_factory, _sandboxes) flow through the application layer.
DaytonaRuntimeProvider.exec reaches into sandbox.process.exec
directly. Worktree paths use only branch_name (vs. local's
<user>_<scope>_<branch>), which weakens isolation guarantees.
8. No git-platform port. PR creation is not represented in the
sandbox layer. commit and push exist on SandboxClient, but
creating a PR has to bypass the sandbox abstraction.
9. Tool surface uses contextvars instead of explicit dependencies.
tool_functions._resolve reads user_id from a contextvar and looks
up repo_name via DB. The agent harness sets the contextvar at
pydantic_agent.py:613-621. A toolset factory should take
(client, run_context) explicitly.
10. No eviction port / no per-conversation cleanup hook.
SandboxStore lacks list_workspaces_by_repo,
find_workspaces_for_eviction, etc. EDIT-mode worktrees are only
reaped by RepoManager's age-based eviction; no
release_session(conversation_id) exists.
---
Layered Architecture
Agent / Tool layer
|
v
SandboxService
- resolve repo identity
- get/create repo cache
- get/create workspace
- attach/resume runtime
- enforce locks, TTL, eviction
|
+--> RepoCacheStore / WorkspaceStore / RuntimeStore
|
+--> RepoCacheManager
| local implementation wraps existing RepoManager
|
+--> WorkspaceManager
| local implementation creates git worktrees
|
+--> RuntimeProvider port
DockerProvider / DaytonaProvider / E2BProvider / LocalReadOnlyProviderThe repo cache and workspace layers own persistence. The runtime provider owns
execution.
---
Public Service API
Tools and agents should call SandboxService, not backend adapters directly.
app/modules/sandbox/service.py
from dataclasses import dataclass
from typing import Literal, Optional
WorkspaceMode = Literal["analysis", "edit", "task"]
@dataclass(frozen=True)
class WorkspaceRequest:
user_id: str
project_id: str
repo_name: str # "owner/repo"
repo_url: Optional[str]
base_ref: str # branch or commit
mode: WorkspaceMode
conversation_id: Optional[str] = None
task_id: Optional[str] = None
branch_name: Optional[str] = None
create_branch: bool = False
@dataclass(frozen=True)
class RuntimeRequest:
workspace_id: str
image: str
env: dict[str, str]
writable: bool = True
network: Literal["none", "limited", "default"] = "limited"
timeout_s: Optional[int] = None
class SandboxService:
async def get_or_create_workspace(
self, request: WorkspaceRequest
) -> "Workspace":
...
async def get_or_create_runtime(
self, request: RuntimeRequest
) -> "SandboxRuntime":
...
async def exec(
self,
workspace_id: str,
request: "ExecRequest",
) -> "ExecResult":
...
async def hibernate_runtime(self, runtime_id: str) -> None:
...
async def destroy_runtime(self, runtime_id: str) -> None:
...
async def destroy_workspace(self, workspace_id: str) -> None:
...
Convenience methods can exist for common tool flows:
async def get_or_create_analysis_workspace(user_id, project_id) -> Workspace: ...
async def get_or_create_edit_workspace(user_id, project_id, conversation_id) -> Workspace: ...
async def run_command(workspace_id, command) -> ExecResult: ...---
Runtime Port
The provider port should be about compute, not repo cloning policy.
/ Detailed source-code truncated for AI context efficiency. /Optional Capabilities
Do not rely on isinstance(provider, SomeProtocol) unless the protocol is
runtime-checkable. Prefer explicit optional capability accessors.
@dataclass(frozen=True)
class RuntimeCapabilities:
snapshot: bool = False
preview_url: bool = False
interactive_session: bool = False
class SnapshotCapability(Protocol):
async def snapshot_runtime(self, runtime_id: RuntimeId, label: str | None = None) -> str: ...
async def restore_runtime(self, snapshot_id: str) -> SandboxRuntime: ...
class PreviewURLCapability(Protocol):
async def expose_port(self, runtime_id: RuntimeId, port: int) -> str: ...
class SessionCapability(Protocol):
async def open_session(self, runtime_id: RuntimeId) -> "Session": ...
Provider construction can expose these as attributes:
provider.snapshotter: SnapshotCapability | None
provider.preview_urls: PreviewURLCapability | None
provider.sessions: SessionCapability | None---
Storage Model
Persist this in Postgres, even if the first implementation only supports local
filesystem paths.
sandbox_repo_caches
One row per durable repo mirror.
Recommended columns:
- id
- repo_name
- repo_url
- provider
- provider_host
- local_path
- auth_scope_hash
- last_fetched_at
- last_used_at
- size_bytes
- state
- created_at
- updated_at
Local implementation can mirror existing .repos/.meta data, then migrate more
fully later.
sandbox_workspaces
One row per durable worktree/workspace.
Recommended columns:
- id
- repo_cache_id
- project_id
- user_id
- mode
- conversation_id
- task_id
- base_ref
- branch_name
- local_path
- state
- dirty
- pinned_until
- last_used_at
- size_bytes
- created_at
- updated_at
Suggested uniqueness:
- Analysis workspace: (user_id, project_id, base_ref, mode) where
mode = "analysis".
- Edit workspace: (user_id, project_id, conversation_id) where
mode = "edit".
- Task workspace: (user_id, project_id, task_id) where mode = "task".
sandbox_runtimes
One row per runtime attachment.
Recommended columns:
- id
- workspace_id
- backend_kind
- backend_runtime_id
- image
- state
- last_started_at
- last_used_at
- expires_at
- created_at
- updated_at
Runtime records can be deleted aggressively. Workspace records should survive
runtime destruction.
---
Workspace Lifecycle
Analysis Flow
Use this for read-only code search and exploration.
1. Resolve project_id to repo details.
2. Ensure repo cache exists using RepoManager.ensure_bare_repo or
prepare_for_parsing.
3. Get or create a clean worktree for the requested branch/commit.
4. Attach a read-only runtime when possible.
5. Run whitelisted read-only commands.
This can continue using bash_command plus gvisor_runner.py initially, but
the tool should treat gVisor fallback as reduced isolation and keep commands
read-only.
Edit Flow
Use this for feature work, bug fixes, tests, and PR creation.
1. Resolve project_id to repo details.
2. Ensure repo cache exists.
3. Create or reuse an edit workspace keyed by conversation_id.
4. Create branch agent/edits-{conversation_id} from the project base branch.
5. Attach a write-capable runtime with the workspace mounted writable.
6. Run commands and edits inside that runtime.
7. Commit/push/PR from the same workspace.
This should replace the current split where generated changes live in Redis and
then get applied to the worktree. Redis can remain a UI/change-tracking cache,
but the workspace should become the source of truth for actual files.
Task Flow
Use this for multi-agent or background work where one conversation may spawn
multiple independent implementation attempts.
1. Key workspace by task_id.
2. Create branch agent/task-{task_id} or a child branch from the edit branch.
3. Keep each task workspace isolated.
4. Merge/cherry-pick/diff back into the conversation edit workspace if accepted.
---
Eviction Policy
Eviction should be tiered. Compute is cheap to recreate; repo clones are
expensive.
Runtime Eviction
Default behavior:
- Stop idle runtimes after 15-30 minutes.
- Destroy stopped runtimes after 1-6 hours.
- Never delete the workspace as part of runtime eviction.
Triggers:
- Idle timeout.
- Backend quota pressure.
- Explicit user/project cleanup.
- Failed runtime health checks.
Workspace Eviction
Default behavior:
- Keep edit workspaces for longer than runtimes, for example 7-30 days.
- Keep dirty or recently active workspaces longer.
- Never evict pinned workspaces.
- Prefer evicting clean analysis workspaces before dirty edit workspaces.
Suggested priority:
1. Clean analysis workspaces older than TTL.
2. Clean task workspaces older than TTL.
3. Clean edit workspaces older than TTL.
4. Dirty unpinned task workspaces under disk pressure.
5. Dirty unpinned edit workspaces only under severe disk pressure.
Before deleting a dirty workspace, consider one of:
- Create a patch artifact.
- Commit to an internal branch.
- Mark it as eviction_blocked and alert/admin-log.
Repo Cache Eviction
Default behavior:
- Keep repo caches longest.
- Evict only when disk pressure remains after runtime and workspace eviction.
- Use LRU with size awareness.
- Do not evict a repo cache with active workspaces.
The existing RepoManager already has tiered eviction hooks:
- Worktree threshold: _WORKTREE_EVICTION_THRESHOLD_PERCENTAGE
- Repo threshold: _REPO_EVICTION_TARGET_PERCENTAGE
- Stale worktree age: _STALE_WORKTREE_MAX_AGE_DAYS
The sandbox service should reuse this behavior first, then move metadata into
Postgres when needed.
---
Concurrency and Locking
Concurrency needs to be explicit because git worktrees and package managers are
not safe under arbitrary parallel mutation.
Use DB uniqueness plus advisory locks:
- Lock repo cache creation by normalized repo identity.
- Lock workspace creation by workspace uniqueness key.
- Lock mutating commands per workspace.
- Allow concurrent read-only commands when no mutating command is running.
Command classes:
- read: rg, grep, cat, ls, static inspection.
- write: file edits, git checkout, git add, git commit, formatters.
- install: package manager commands, dependency resolution.
- test: usually read-mostly but often writes build artifacts; treat as
mutating unless mounted with a separate cache/output directory.
For the first implementation, a conservative per-workspace async lock around
all write-capable runtime commands is acceptable.
---
Security Rules
- Do not pass host environment variables into runtimes by default.
- Inject only scoped credentials that the command actually needs.
- Keep git credentials out of remotes after clone/fetch.
- Do not mount host repo paths broadly; mount only the selected workspace.
- For write-capable work, do not silently fall back to host subprocess.
- Default network to limited or none; allow broader network only for
explicit workflows like dependency install.
- Validate all file paths against the workspace root.
- Treat user-provided repo names, branches, and paths as untrusted input.
Important local-mode rule:
gvisor_runner.py is acceptable for the existing read-only analysis path. It
should not be used as the write-capable sandbox runtime because it mounts
read-only and has regular-subprocess fallback behavior.
---
Adapter Responsibilities
| Adapter | Runtime backing | Workspace persistence | Good first use | Notes |
| --- | --- | --- | --- | --- |
| LocalReadOnlyProvider | existing gVisor runner | local worktree | read-only analysis | No write workflows; no silent trust in fallback |
| DockerProvider | long-lived container | local worktree or Docker volume | first write-capable backend | Best first implementation target |
| DaytonaProvider | Daytona workspace/runtime | native or synced workspace | managed sandbox backend | Use native snapshots/previews when useful |
| E2BProvider | Firecracker microVM | native or synced workspace | fast ephemeral runtime | Validate persistence semantics carefully |
Local Daytona development notes:
- One-shot setup: app/src/sandbox/scripts/setup-daytona-local.sh brings the
Daytona compose stack up. The script layers
scripts/daytona-overrides/docker-compose.override.yaml on top of the
upstream compose so the dashboard host port is 3010 by default (port
3000 is reserved for the potpie frontend); the override also mounts a dex
config that whitelists http://localhost:3010 as an OIDC redirect URI.
Set DAYTONA_DASHBOARD_PORT=... before running the script to remap to
another port.
After the stack starts the script waits for /api/health, mints a dev API key for
the bundled [email protected] / password user, sets a default region if
needed, writes app/src/sandbox/.env.daytona.local, and prints the URL of
every observability dashboard the compose file already ships:
- Daytona dashboard: http://localhost:3010
- Sandbox snapshots: http://localhost:3010/dashboard/snapshots
- Active sandboxes: http://localhost:3010/dashboard/sandboxes
- Swagger API: http://localhost:3010/api
- Jaeger traces: http://localhost:16686
- pgAdmin: http://localhost:5050
- Container registry UI: http://localhost:5100
- MinIO console: http://localhost:9001 (minioadmin / minioadmin)
- MailDev: http://localhost:1080
- Uses the Daytona compose stack vendored in-repo at
app/src/sandbox/daytona/ by default (no external clone needed). To build
Daytona images from source instead, point at a clone with
DAYTONA_REPO_PATH=/path/to/daytona.
- Do not pass --project-directory to docker compose. Volume binds in the
Daytona compose file are relative to the compose file's own directory and
silently auto-create stub directories at the wrong location otherwise.
- Cleanup: app/src/sandbox/scripts/teardown-daytona-local.sh deletes only
sandboxes labelled managed-by=potpie. Pass --stack to also bring the
compose stack down with -v.
- The bundled daytonaio/sandbox:0.5.0-slim snapshot does not ship the
git CLI. The Daytona adapter handles this by using the toolbox git.clone
/ git.create_branch / git.checkout_branch endpoints and verifying
outcomes by reading .git/HEAD directly (the toolbox SDK occasionally
raises DaytonaValidationError("...: ") on operations that actually
succeeded).
- Tests resolve proxy.localhost to 127.0.0.1 in-process (see
tests/e2e/conftest.py) so neither sudo nor the setup-proxy-dns.sh
dnsmasq script is required for E2E runs. Long-lived shell access still
needs the dnsmasq setup or an /etc/hosts entry.
- Adapter env vars (sourced from .env.daytona.local):
SANDBOX_WORKSPACE_PROVIDER=daytona, SANDBOX_RUNTIME_PROVIDER=daytona,
DAYTONA_API_URL, DAYTONA_API_KEY, optional DAYTONA_SNAPSHOT,
DAYTONA_WORKSPACE_ROOT.
The adapter must:
- Translate runtime lifecycle and exec calls.
- Map backend errors into typed sandbox errors.
- Enforce its declared mount/network behavior.
- Report capabilities truthfully.
The adapter must not:
- Decide which user/repo/workspace should be used.
- Own the clone-on-create policy.
- Reach into Postgres workspace mappings directly.
---
Typed Errors
Every adapter should map backend-specific failures into a small error set.
class SandboxError(Exception): ...
class SandboxNotFound(SandboxError): ...
class SandboxUnauthorized(SandboxError): ...
class SandboxTimeout(SandboxError): ...
class SandboxConflict(SandboxError): ...
class SandboxUnavailable(SandboxError): ...
class SandboxResourceLimit(SandboxError): ...
class SandboxCommandRejected(SandboxError): ...Service-level repo/workspace errors should be separate:
class WorkspaceError(Exception): ...
class WorkspaceNotFound(WorkspaceError): ...
class WorkspaceLocked(WorkspaceError): ...
class WorkspaceDirty(WorkspaceError): ...
class RepoCacheUnavailable(WorkspaceError): ...
class RepoAuthFailed(WorkspaceError): ...---
Implementation Roadmap
The original Phase 1-5 plan is complete — the sandbox module, the
runtime port, the Daytona adapter, the agent tool surface, and the
Postgres-ready store are all shipped. The post-revamp phases (P1-P8)
each address a numbered gap from the audit. Status as of this
session:
P1 — Unify the local provider — DONE
Closes gaps 1, 2, 3.
* EvictionPolicy port at domain/ports/eviction.py with
EvictionResult value type; NoOpEvictionPolicy default in
adapters/outbound/memory/eviction.py.
* LocalGitWorkspaceProvider accepts eviction= kwarg, calls
evict_if_needed on cache miss.
* LocalGitWorkspaceProvider is now the only local provider. The
RepoManagerWorkspaceProvider bridge and the
SANDBOX_USE_CANONICAL_LOCAL flag have been removed; sandbox traffic
goes through the canonical adapter unconditionally.
* Operator note: any on-disk worktrees created by the old bridge
(layout: <branch> for shared, <user>_<unique>_<branch> for
conversation-scoped) are not visible to the canonical adapter. New
conversations get fresh worktrees in the <user>_<scope>_<branch>
layout; commit/push any in-flight bridge worktree state before
upgrading.
* Volume-aware policy (VolumeBasedEvictionPolicy) replacing the
NoOp default is still a P1 follow-up; the canonical path runs
unbounded until the policy is wired.
P2 — Promote RepoCache to first-class — DONE
Closes gap 5.
* RepoCacheRequest and RepoCache (with stable key) in
domain/models.py.
* RepoCacheProvider port at domain/ports/repos.py.
* RepoCacheStore mixin on SandboxStore; both InMemorySandboxStore
and JsonSandboxStore implement it (rows survive restart).
* LocalRepoCacheProvider at
adapters/outbound/local/repo_cache.py owns bare-repo creation;
LocalGitWorkspaceProvider depends on the cache port and sets
Workspace.repo_cache_id on every workspace it builds.
* SandboxService.ensure_repo_cache(request) keys by repo identity,
takes a per-key lock, persists the row.
P3 — Capabilities split and acquire_session API — DONE
Closes gaps 6 and 10.
* Capabilities(writable, isolated, persistent) value object;
Capabilities.from_mode is the single source of truth.
* Workspace.capabilities populated by every adapter (local, bridge,
Daytona). Round-trips through JsonSandboxStore.
* SandboxService.acquire_session(request) orchestrates ensure-cache
+ workspace creation atomically.
SandboxService.release_session(workspace_id, , destroy_runtime)
hibernates the runtime by default; workspace survives.
* SandboxClient.acquire_session / release_session symmetric public
API.
P4 — Daytona hardening — partial
Addresses gap 7 (correctness portion).
Done:
* _validate_ref in daytona/provider.py rejects newlines / .. in
base_ref and branch_name before they hit shell-style exec calls
(parity with the local adapter).
* Worktree path now <user>_<scope>_<branch> so two conversations on
the same branch get distinct worktrees (no silent collision).
Deferred (code-quality follow-up): The Daytona SDK still types asAny on _client_factory/_sandboxes; the full DaytonaApi Protocol
abstraction is a 500+ line refactor that doesn't fix any correctness
issue. Track as a P4.5 cleanup.
P5 — Provision-on-parse — DONE
Closes gap 4.
* SandboxClient.ensure_repo_cache(...) thin wrapper over the service.
* provision_repo_cache helper at
app/modules/intelligence/tools/sandbox/client.py.
* parsing_service.py calls _provision_repo_cache_safe from BOTH
READY transitions (eager-return short-circuit and the normal
post-analyze_directory exit). Failures are logged and swallowed —
cache provisioning is an optimization, not a parsing prerequisite.
P6 — Tool surface refactor — DONE
Closes gap 9.
* create_sandbox_tools(client=..., handle=...) — explicit-handle
factory mode. Tools dispatch through pre-bound (client, handle)
closures with input schemas that omit project_id. Capability
gating drops write tools when the handle is read-only
(enforce_capabilities=True default).
* The legacy zero-arg create_sandbox_tools() form keeps working —
required for back-compat with the existing harness wiring at
multi_agent/agent_factory.py and pydantic_agent.py.
* Helper functions extracted in tool_functions.py:
_exec_text_editor, _exec_shell, _exec_search, _exec_git,
_exec_pull_request. Both factory modes funnel through the same
helpers.
* WorkspaceHandle.capabilities carries the gating signal.
* Contextvar machinery in tools/sandbox/context.py is kept for
the legacy form — full removal happens once harness callers migrate
to the explicit form (P6.5 cleanup).
P7 — GitPlatformProvider and PR tool — DONE
Closes gap 8.
* GitPlatformProvider port at domain/ports/git_platform.py;
PullRequestRequest / PullRequest value objects in
domain/models.py; PullRequestFailed /
GitPlatformNotConfigured errors.
* GitHubGitPlatformProvider bridge adapter at
app/modules/sandbox_repos/git_platform.py wraps the existing
code_provider.github.GitHubProvider so auth chain stays put.
* SandboxService.create_pull_request(request) enforces "platform
configured" precondition; SandboxClient.create_pull_request(handle, enforces "writable workspace" precondition.
...)
* sandbox_pr agent tool — included in the explicit toolset only when
the harness passes `pr_repo_name=...; capability-gated onsandbox_git push
writable handles. Push the branch via first; theSandboxClient
PR tool is the platform-side step only.
* Production wiring of the platform provider is a follow-up. The
per-call user resolution (auth tokens scoped to the calling user)
doesn't fit the process-wide cleanly; needs a small
request-scoped factory before it can run in prod.
P8 — Postgres store and multi-worker locks — DEFERRED
Closes the implicit single-node assumption in JsonSandboxStore andInMemoryLockManager. The schema sketch is already documented abovesandbox_repo_caches
(, sandbox_workspaces, sandbox_runtimes).
Implementation requires a Postgres connection and migration tooling
that aren't in this session's scope. Adapter signatures will mirror
the existing in-memory and JSON ones, so the swap is a bootstrap-only
change.
Track as a follow-up. The current JsonSandboxStore is suitable
for single-node deployments; multi-worker / multi-host setups need the
Postgres adapter, since the JSON store's flush model assumes a single
writer.
Outstanding follow-ups (small)
* P1 follow-up — VolumeBasedEvictionPolicy reading fromSandboxStore
so canonical-local has bounded disk use.DaytonaApi
* P4 follow-up — Protocol port to remove Any typing.multi_agent/agent_factory.py
* P6 follow-up — migrate harness callers
(, pydantic_agent.py) to theGitPlatformProvider
explicit-handle form, then delete the contextvar plumbing.
* P7 follow-up — request-scoped factory so the
per-user auth chain works in production.
* P8 — Postgres adapters once the DB story lands.
---
Open Questions
- Should edit workspace persistence be keyed by conversation_id, by an
explicit "agent task/session id", or both?
- When a dirty workspace is old enough to evict, should Potpie auto-commit to
an internal branch, save a patch artifact, or block eviction?
- Should dependency caches be per-user, per-repo, or per-workspace?
- How much network access should test/install commands get by default?
- Should local Docker workspaces mount host worktrees directly, or should they
copy/sync into a Docker volume for stronger host isolation?
---
Sandbox Integration Plan
Sandbox Integration Plan
This document plans the next phase of app/src/sandbox: turning it into theapp/modules/repo_manager
single library that everything else in potpie uses to materialize repos and run
code, and retiring the + app/modules/intelligence/
tools/code_changes_manager stack.
It is a plan, not a spec. Where there are real tradeoffs the doc names them
and points at the surfaces involved, but leaves the call to the implementer.
Read docs/sandbox-core-setup.md first — this builds on the model definedRepoCache
there ( / Workspace / Runtime).
---
1. Where we are
The sandbox module today (app/src/sandbox/sandbox/) is hexagonal and runnable:
- Domain: Workspace, Runtime, RepoIdentity, WorkspaceRequest,ExecRequest
, etc. (domain/models.py).WorkspaceProvider
- Ports: , RuntimeProvider, SandboxStore, LockManagerdomain/ports/*
().adapters/outbound/*
- Adapters: local-git workspace, local-subprocess runtime, docker runtime,
daytona workspace + runtime, file/json store, in-memory locks
().SandboxService
- Application service: (application/services/sandbox_service.py)get_or_create_workspace
exposes , get_or_create_runtime, exec,hibernate_runtime
, destroy_runtime, destroy_workspace. It isdirty
idempotent, locks per-workspace, and tracks after writes.scripts/setup-daytona-local.sh
- E2E tests cover all three runtime backends end-to-end (subprocess, docker,
daytona). Daytona dev stack is bootstrapped via
and the override compose file.
The rest of potpie still drives a parallel stack:
- app/modules/repo_manager/ — bare repo + worktree management on disk under.repos/<owner>/<repo>/
. Has its own auth chain, eviction, metadata.app/modules/intelligence/tools/code_changes_manager/
- — Redis-backedStructuredTool
per-conversation file-edit staging area, with ~20 LangChain
s wrapped around it (add_file_to_changes,update_file_lines
, replace_in_file, …, plus git_commit, git_push,bash_command
).app/modules/parsing/
- Parsing () calls RepoManager.prepare_for_parsing() toos.walk
get a worktree path and walks it directly with /Tree-sitter.app/modules/intelligence/agents/
- Agents (PydanticAI in ) get tools viaToolService
+ ToolResolver, with ChatContext (project_id, user_id,conversation_id
, branch, local_mode) carrying scope.
The two stacks duplicate concepts: both clone, both manage worktrees, both
think about branches. The plan unifies them under the sandbox module.
---
2. Goals
- A small, library-shaped public API on app/src/sandbox that the rest of(repo, branch, user, project)
potpie can import and use to: get a sandbox, get a working tree on a branch,
read/write files, run commands, search, commit, push.
- Repo lifecycle owned by sandbox. The caller passes
— sandbox does the cloning, the worktreerg
creation, the branch switching, and (eventually) the eviction. Parsing and
agents both go through it.
- Pre-provisioned tooling. Common dev tools (, git, fd, jq,python
, node) are present in the runtime out of the box, so agent tools(user, project)
don't need to detect/install at runtime.
- One backend sandbox, many branches. A single backend container per
hosts multiple worktrees so multiple agent runs onToolService
different branches share the same expensive thing (the Daytona sandbox /
Docker container) without serializing.
- Sandbox-backed agent tools ship from the sandbox library itself,
registered into like any other tool. Each tool resolves itsCodeChangesManager
workspace from the agent's run context.
- is gone. Edits go straight to the worktree (which.repos
is durable git state). The Redis-staged-changes model is replaced by
"edit the file, commit when done." If we want stage-and-review semantics
later they live above the sandbox, not inside it.
- becomes one adapter under the sandbox WorkspaceProvider
port, used for local development and tests. Daytona is the production path.
Non-goals
- Replacing the agent framework, the LLM provider abstraction, or the search
service. The sandbox owns "where the bits live" — not "how the agent
thinks."
- Full distributed locking. The InMemoryLockManager is fine for the single-code_changes_manager
worker case; a Redis-backed lock manager is a separable later concern.
- Snapshot-based hibernation as a primary persistence mechanism. Git worktrees
+ the backend's volume are the persistence.
- Backwards compatibility with the Redis schema. We are
removing it; there's no migration.
---
3. Conceptual additions
The current sandbox model (one Workspace = one branch) is right for what it
solves, but the integration goals push two extensions:
3.1 RepoCache becomes load-bearing
RepoCache exists in domain/models.py (lines 116–127) but isn't wired in.
The plan promotes it to a real concept owned by the workspace provider:
- RepoCache = "the bare clone, somewhere a backend can reach it."Workspace
- = "a working tree on top of a RepoCache, scoped to a branch."RepoCache
- One per (user, repo) — or per (repo) if we trust sharedWorkspace
cache semantics; defer that call until we actually share across users.
- Many per RepoCache, one per active branch.
In adapter terms:
| Backend | RepoCache materialization | Workspace materialization |
| ---------------- | ------------------------------------------- | -------------------------------------- |
| Local git | .repos/<owner>/<repo>/.bare/ | .repos/.../worktrees/<key>/ |
| Daytona | Bare clone inside a long-lived sandbox | Worktree in the same sandbox |
| Docker | Bare clone in a named docker volume | Worktree mounted into the runtime |
The Daytona row is the interesting one: today each WorkspaceRequest creates(user, project)
its own Daytona sandbox. The new model creates one Daytona sandbox per and uses git worktree inside it for each branch. That's
how we get multi-branch concurrency without paying for N sandboxes.
The implementer should decide whether to:
- (a) Add a RepoCacheProvider port alongside WorkspaceProvider, orWorkspaceProvider
- (b) Fold the cache semantics into (one provider, twoRepoCache
conceptual layers), or
- (c) Keep purely as a value object that providers populate
internally.
(b) or (c) is probably right — adding a port costs more than it pays for
unless we expect to swap cache backends independently.
3.2 Workspace as a branch handle
A Workspace already has metadata["branch"] and a worktree path. The plan
keeps that, but tightens the lifecycle:
- Creation: clone or fetch into the cache, then git worktree add for theWorkspace
branch.
- Switch-branch: not actually a switch — it's "give me a different
on the same RepoCache." The caller asks for a workspace by(user, project, branch)
and gets the right worktree.SandboxService.get_or_create_
- Reuse: the existing key-based idempotency in
workspace already does this (line 50). Verify the key formula handles the(repo, branch)
case cleanly without conversation_id or task_iddestroy_workspace
required.
- Cleanup: removes the worktree but keeps the cache.
A separate path destroys the cache (or the whole backend container).
This is the biggest behavioral change. Today the daytona adapter
(adapters/outbound/daytona/provider.py:131-152) callsdaytona.create(...) per workspace. After the change, sandbox creation is(user, project)
keyed on and worktree creation runs inside an existing
sandbox.
---
4. The public client API
What the rest of potpie should be able to import. Treat names as suggestions.
4.1 The package surface
app/src/sandbox/sandbox/__init__.py
# The library export
SandboxClient
SandboxClientConfig
# Re-exports of the small set of types callers actually need:
WorkspaceHandle, ExecResult, NetworkMode, CommandKind,
SandboxError, WorkspaceNotFound, ...SandboxClient is a thin façade over SandboxService plus the bootstrap
container. The intent: callers don't construct providers, stores, locks, or
build runtime specs by hand. They get a client and ask for what they need.
Sketch — flesh out in code:
/ Detailed source-code truncated for AI context efficiency. /4.2 What WorkspaceHandle is
A small, opaque object containing the IDs the client needs to talk to the
service: workspace_id, the resolved branch, the runtime working dir.Workspace
Not the domain object — the handle is the stable thing returnedWorkspace
to callers. Internally the client looks up the live each call.
Why opaque: it lets the implementer change what's stored (a single ID vs a
small struct vs a Workspace snapshot) without churning every caller.
4.3 Read/write helpers vs exec
The existing exec() is enough to do everything, but agent tools end upread_file
shelling out for trivial operations and re-implementing argument quoting.
The helpers (, write_file, list_dir, search) are first-class
on the client because:
- They have a fixed surface that's easy to mock / fake in tests.
- They can dispatch to backend-native APIs when available (Daytona toolbox
has fs.upload_file, fs.download_file, git.* — currently only used by
the daytona workspace provider).
- They give the agent prompt a small, named tool surface (see §7).
The implementer should look at Daytona's toolbox API
(adapters/outbound/daytona/provider.py, lines 154–228 are precedent) andRuntimeProvider
wire the helpers through so each backend has one chanceexec
to do it natively before falling back to .
4.4 What we deliberately don't expose (yet)
- No Runtime lifecycle on the client. Runtime is implicit; exec bringshibernate
it up. / destroy show up as release_workspace.exec_stream
- No streaming exec. exists in the port but the inboundRuntimeCapabilities
surface stays sync-result for v1; revisit when an agent flow actually
benefits.
- No "preview URL" / port-forwarding. already models
this; expose it when there's a caller.
---
5. Preinstalled tooling
Today the runtime images are minimal: python:3.12-slim for docker default,daytonaio/sandbox:0.5.0-slim for daytona, host PATH for subprocess.rg
Nothing has (ripgrep), and the Daytona slim image doesn't even ship gitprovider.py:182-185
CLI — the daytona adapter explicitly works around that with the toolbox API
(see ).
The plan: ship our own image for the docker and daytona backends with a
known-good toolset.
5.1 Image contents (proposal)
A single Dockerfile in app/src/sandbox/images/agent-sandbox/ producespotpie/agent-sandbox:<version>:
- Base: python:3.12-slim (or debian:stable-slim if startup time is angit
issue and we want a cheaper base).
- Tools: , git-lfs, ripgrep, fd-find, jq, curl, ca-certificates,tini
, tree, less, coreutils, procps, openssh-client.pip
- Runtimes: Python (with , uv optional), Node LTS, GitHub CLI (gh)agent
if we want it for PR creation.
- A non-root user with $HOME persistent — the worktree will live/home/agent/work/
under .
- An entrypoint that pre-creates the worktrees dir and exec's the requested
command (mirrors what the override compose entrypoint does today).
The implementer should check the actual size — going from slim+toolbox to
full-fat is a real tradeoff for Daytona cold-start. If that's painful, split:
a "lean" image without Node and a "full" image with everything; pick per
workload.
5.2 Daytona snapshot
Daytona consumes images via "snapshots." The override compose stack already
has a flow for this (scripts/setup-daytona-local.sh). Process:
1. Build the image.
2. Push to a registry Daytona can pull from. (For dev: the local docker daemon.)
3. Run daytona snapshot create potpie/agent-sandbox:<version>DAYTONA_SNAPSHOT
(or however the Daytona SDK phrases it — check existing scripts).
4. Default env var to the new snapshot in .env.daytona.localbootstrap/settings.py
and in .
Tests: extend tests/e2e/test_daytona_e2e.py with a sanity check thatrg --version and git --version succeed inside a fresh sandbox.
5.3 Subprocess backend
The local subprocess backend is purely for tests and dev-machine debugging.
It uses host PATH. We don't ship anything; we document that rg should be
on PATH. Tests already skip when not available.
---
6. Repo lifecycle in the new model
6.1 Parsing path
Today ParseHelper.clone_or_copy_repository (app/modules/parsing/)RepoManager.prepare_for_parsing
calls and walks the resulting path withos.walk. Tomorrow:
ParsingService.parse_directory(repo_details, ...)
→ SandboxClient.get_workspace(
user_id, project_id, repo, branch=base_ref,
mode=WorkspaceMode.ANALYSIS, create_branch=False,
)
→ handle = ...
→ run parsing against handle (see below)
→ SandboxClient.release_workspace(handle) # or keep for re-parseHow parsing actually reads files matters:
- Option A (recommended, easier): parsing keeps running on the host;
the sandbox client just produces a host-readable path. For the local-fs
backend this is the existing .repos/.../worktrees/.... For Daytona,
this means: don't run parsing in Daytona — run the cheap clone in
Daytona only when the agent actually needs a sandbox, and use the
local-fs backend for parsing. Two backends, one client API, picked per
workload.
- Option B (harder): parsing runs inside the sandbox (rust extractor
baked into the image, results streamed back). The agent (Map parsing
flow & repo dependency writeup, §10) flagged GitPython fork-safety,
Rust backend distribution, and embedding model size as the costs. Worth
doing if we want a uniform model for managed-cloud customers, but not
the v1.
Pick A. Make sure the client's helpers don't accidentally tie callers to
"the path is on the host." The WorkspaceHandle should expose alocal_path: str | None field that is populated only for local-fs backendsNone
and for daytona — parsing checks for it and skips Daytona for now,
or refuses with a clear error.
6.2 Agent path
Today: agent tool fires, calls RepoManager to get a worktree, writesCodeChangesManager
through to Redis, later commits.
Tomorrow:
ChatContext { project_id, user_id, conversation_id, branch, ... }
→ SandboxClient.get_workspace(
user_id, project_id, repo, branch=branch,
mode=WorkspaceMode.EDIT, create_branch=True,
)
→ handle is cached on the agent run (see §7.3 for where)
→ tools call client.{read_file, write_file, search, exec, commit, ...}
→ run ends → release_workspace (don't destroy the worktree yet — keep it
until conversation cleanup).The branch model: WorkspaceMode.EDIT already names branchesagent/edits-{conversation_id} (adapters/outbound/local/git_workspace.py,(user, project)
lines 204-211). Keep that. For the daytona backend the branch lives as a
worktree on a shared sandbox per .
6.3 Switching branches
User asked for "checkout somehow when plugging to the agent." Two
interpretations:
- (a) Same conversation, switch branch — operator wants to abandon the
current branch and resume on another. Express as: release the old
workspace, get a new workspace with a different branch.
- (b) Same backend container, multiple branches in flight — multiple
conversations / agents, one Daytona sandbox. Express as: each
get_workspace returns a new worktree inside the existing sandbox.
(b) is the real concurrency story; (a) is a special case of "release +
get_or_create." The implementer shouldn't add a switch_branch operation
on the client — it's just two existing calls.
6.4 Cleanup
Three layers:
1. Worktree (cheap): destroy on conversation close, or after N days idle.
2. Backend sandbox (expensive in $$$): destroy on user inactivity, or per
organization quota. Hibernate (Daytona auto-stops after 30s; auto-archives
after 12h — already configured in
adapters/outbound/daytona/provider.py:142).
3. Repo cache (expensive in disk): LRU evict, or per-repo TTL.
Today's RepoManager._evict_if_needed (the writeup §3 in the
code-changes-manager map) is a reasonable starting point. Port it as a
background task that the local-fs adapter runs, not as inline-during-
clone behavior — that surprised people. Daytona handles its own GC for
sandboxes; we only need to run cache cleanup if we end up storing caches
in named volumes.
---
7. Agent tool exports
The user wants a set of tools they can plug into agents that gives the
agent access to a specific (repo, branch) sandbox.
7.1 Tool catalog (proposal)
Ship a small, opinionated set. Names are starting points — match the
existing tool naming style (snake_case, verbs). Each tool's input schema
includes project_id (the agent already passes this) and the input getsWorkspaceHandle
resolved to a at call time.
| Tool | Wraps | Agent uses for |
| -------------------------- | ------------------------------------------- | ----------------------------------- |
| sandbox_read_file | client.read_file | view a file (line numbers optional) |sandbox_write_file
| | client.write_file | full-file replace |sandbox_str_replace
| | client.read_file + write | targeted in-file edit |sandbox_list_dir
| | client.list_dir | navigate |sandbox_search
| | client.search (ripgrep) | grep across the tree |sandbox_run
| | client.exec | run any command (whitelisted?) |sandbox_run_tests
| | client.exec + framework detection | language-aware test runner |sandbox_git_status
| | client.status | what changed |sandbox_git_diff
| | client.diff | review changes |sandbox_git_commit
| | client.commit | commit |sandbox_git_push
| | client.push | push (auth-token aware) |sandbox_open_pr
| | provider-specific PR creation | end of feature flow |
The implementer should compare to today's code_changes_manager toolsclear_file
(20-ish tools across staging operations) and consciously drop the ones
that exist only because edits were staged in Redis — ,get_changes_summary, serialize, revert_file, etc. The agent operates
directly on the worktree; Git is the source of truth and the audit log.
7.2 Where the tool code lives
Two reasonable placements:
- Inside the sandbox library, exported as factory functions. Tools
import only from the public client API. This is cleanest — the sandbox
library is self-contained.
- In app/modules/intelligence/tools/sandbox/, depending only on
the sandbox client. This matches the rest of the tool layout.
Pick the second. Sandbox stays a pure library; potpie-specific glue
(StructuredTool wrapping, ToolService registration, ChatContext binding)
stays where the rest of that glue lives. The library only exports
data-shaped helpers, not LangChain StructuredTools.
7.3 How tools resolve the workspace
The tool needs to know (user_id, project_id, repo, branch) to callclient.get_workspace. Today ChatContext already carries all fourapp/modules/intelligence/agents/chat_agent.py:71-162
(). Two viable
approaches:
- Closure capture: ToolService.__init__ (or a newSandboxToolFactory
) constructs each tool with the live ChatContextproject_id
for the run. Tools don't take in their args. Pros: agentToolService
can't "lie" about which project. Cons: tool factories need to be
re-instantiated per agent run, which doesn't match the current
lifecycle (per-user singleton).
- Explicit args: tool input schema keeps project_id (matching allcode_query_tools/bash_command_tool.py:455-525
current tools — see ).ChatContext
The tool resolves it on each call. The branch is discovered server-side
from rather than the LLM picking. Pros: fits theProjectService.get_project_from_db_by_id_sync
existing pattern. Cons: agent could in theory pass a different
project_id, but already
validates user-project ownership.
Pick explicit args for parity. Branch comes from ChatContext via abash_command_tool
mechanism the implementer can mirror from (which usesget_or_create_edits_worktree_path and relies on the conversation context).SandboxRunContext
For sandbox tools, that translates to: a small WorkspaceHandle
contextvar set at the top of every agent run with the resolved (or the data needed to fetch it), and tools read it.
7.4 Result shape
Match the existing tools' shape: Dict[str, Any] with success, thebash_command_tool
result data, and explicit truncation flags. 's 80k-charsandbox_run
output limit is a precedent — keep similar for . Forsandbox_search return a list of hits with file:line:snippet; that's
what the LLM actually wants.
7.5 Allow-list and registry
Add a sandbox tool group inapp/modules/intelligence/tools/registry/definitions.py and update thecode_gen
agent allow-lists (, execute, qna etc. — seeagents/chat_agents/system_agents/). The registry-driven pathToolResolver.get_tools_for_agent
() is the preferred entry point;
hardcoded allow-lists should be migrated as part of this same change
since they refer to soon-to-be-removed tool names.
---
8. Concurrency model
The combined goal — multiple agents on different branches — needs three
things to be true:
1. One backend container per (user, project), shared across
conversations. Lock key for "create or attach": repo-cache:{user_id}:
{project_id} (or whatever RepoCache.key() ends up being). Held only
for the create critical section.
2. Per-worktree write isolation. Today's lock key
workspace-command:{workspace_id} is right (application/services/sandbox_service.py:91-96
) — keep it. Two agents
on two branches don't share a lock; two agents *in the same
conversation* serialize.
3. No process-wide singletons. SandboxClient is per-(potpie process)
and stateless except for the underlying provider/store/locks. Multiple
Celery workers each hold their own client; they coordinate through the
store (which becomes interesting — see below).
8.1 Store: in-memory vs durable
InMemorySandboxStore and JsonSandboxStore work fine for one process.PostgresSandboxStore
Multiple Celery workers need something durable + concurrent: a Postgres
table is the path of least resistance (we already have the DB session).
Add a adapter; use the same SandboxStore port20260407_*
unchanged. The implementer should look at the existing context-graph pot
migrations (the recent migration) for precedent on how
sandbox-related schema gets shipped.
8.2 Locks: in-memory vs Redis
Same shape. InMemoryLockManager is fine for tests and single-worker.RedisLockManager
Production wants (we have Redis; thecode_changes_manager's storage layer already uses it). Same LockManager
port — drop in.
The implementer should not block the v1 plan on these; ship the Postgres
store + Redis lock as a follow-up if a single-worker rollout is enough
for the first migration step.
8.3 Cancellation
ChatContext.check_cancelled is the current escape hatch. Sandbox toolsexec
that call long-running should poll it (or pass a timeout thatSandboxClient.exec
respects it). should accept an optionalcancellation_token callable.
---
9. Migration: removing CodeChangesManager and .repos glue
The two systems are intertwined. Order:
1. Stand up the new client surface. Build SandboxClient over theParseHelper.clone_or_copy_repository
existing service, helpers and all. Local-fs backend is enough.
2. Build the new sandbox tools (§7) and register them under their own
names. Don't delete the old tools yet.
3. Move parsing. is the onlyRepoManager
place outside agents that touches . Replace itsRepoManager
calls with SandboxClient.get_workspace (using theapp/modules/parsing/tests/
local-fs backend, ANALYSIS mode). Tests in
should still pass.code_gen_agent
4. Migrate one agent end-to-end — start with sinceCodeChangesManager
it's the most-touched. Replace its hardcoded tool list with the new
sandbox tool names; update its system prompt. Run the existing
end-to-end smoke tests.
5. Delete . Once code_gen_agent is on the newadd_file_to_changes
tools, the staging tools (, update_file_lines,replace_in_file
, etc.) have no callers. Delete the package, the_init_code_changes_manager
Redis schema, the plumbing inexecution_flows.py
, and the imports that scatter from there..repos
6. Make an adapter. At this point RepoManager still existsWorkspaceProvider
as an implementation detail of the local-fs .app/modules/repo_manager/
Refactor: move the bare-repo + worktree code from
intoapp/src/sandbox/sandbox/adapters/outbound/local/repo_cache.py
sync_helper.py
(or similar), keeping the auth chain ('s GitHub App →LocalGitWorkspaceProvider
OAuth → env-token logic) since it's hard-won. The shape of
doesn't change much — it just absorbsRepoManager
's functionality.app/modules/repo_manager/
7. Delete . The legacy module's only job
was to be that adapter; now the sandbox owns it.
Each step ships independently. Steps 1–4 are additive; 5–7 are deletions.
Don't intermix.
9.1 What to do with the staging idea
CodeChangesManager had a useful property: edits were reviewable before
commit. Without it, agent edits land directly in a worktree branch. That's
also reviewable (it's a git branch you can diff against base), and
arguably better — diffing a branch is a tool the LLM and humans both
already understand.
If we miss "show the user a pending-changes summary," that's a UI concern
on top of git diff, not a sandbox concern. Don't put it back in.
9.2 Apply-changes flow
Current flow: apply_changes tool reads from CodeChangesManager Redisapply_changes
and writes files into the worktree. New flow: there is no sandbox_write_file
— each / sandbox_str_replace writes directly. Thegit_commit tool (which is now sandbox_git_commit) commits whatever is
staged. There's nothing to "apply."
9.3 Local mode (VS Code extension)
local_mode in ChatContext switches several tools to talk to a locallocal_mode
tunnel instead of the worktree. The plan: keep the local-mode path in
the agent layer, not the sandbox layer. When is on,SandboxClient
the agent uses a different tool set (the existing tunnel-backed tools)
and bypasses entirely. The sandbox library doesn't need
to know about VS Code.
9.4 Auth token handling
WorkspaceRequest.auth_token is already plumbed (the daytona adapter#__potpie_token__=...
embeds it via the marker; the local adapterx-access-token:...@host
embeds it as ). Tokens are never persistedadapters/outbound/file/json_store.py:125
(). Keep that property — theSandboxClient API takes the token at workspace creation, and the restsync_helper.py
of the system never sees it again. The implementer should keep the
auth-chain code () intact when it moves into the
adapter, since GitHub App → OAuth → env-token is more than the sandbox
should re-derive.
---
10. .repos as an adapter
After §9.6 the local-fs adapter lives at
adapters/outbound/local/. Concretely:
- repo_cache.py: bare-repo lifecycle (clone, fetch, GC, eviction).repo_manager.py
Absorbs the meaningful parts of .git_workspace.py
- (existing, expanded): worktree lifecycle on top ofsubprocess_runtime.py
the cache.
- (existing): host execution.auth.py
- : GitHub App → OAuth → env-token chain. Absorbssync_helper.py
's logic.
Public behavior the local adapter must preserve:
- .repos/<owner>/<repo>/.bare/, .../worktrees/<name>/ paths — forREPOS_BASE_PATH
developer ergonomics and for the existing parsing flow which knows
these paths.
- env var — keep it as the config knob.GH_TOKEN
- , GH_TOKEN_LIST, GITHUB_BASE_URL env vars — keep them.
- The existing eviction policy — port it; don't re-design as part of this.
Things the local adapter may stop doing (because the sandbox layer
above it now does them):
- The .meta/<owner>/<repo>/branch__commit.json files. TheSandboxStore
is now the source of truth for branch/commit/age
metadata. Migrate the data into the store on first read; delete the
files on write.
---
11. Prompt updates
System prompts in app/modules/intelligence/agents/chat_agents/system_
agents/code_gen_agent.py and the generic pydantic_agent.py referencecode_gen_agent.py
the old tool names extensively (lines 30–122 and 413–650+ in; lines 172–216 in pydantic_agent.py).
Update plan:
- Replace tool-name references with the new ones.
- Replace any "use CodeChangesManager to stage edits" framing withsandbox_git_commit
"edit the file directly; the worktree is yours." Make explicit that
edits are durable from the moment the tool returns and that
formalizes them.bash_command_tool
- Add a short "Sandbox tools" section enumerating the catalog, mirroring
how 's description is structured.local_mode
- Verify the branch of the prompt still hangs together
given §9.3 — the local-mode tools stay, just without the sandbox
ones.
The prompts are long. The implementer should diff carefully and run the
agent against a small smoke test (the existing E2E in
agents/integration/) before declaring victory.
---
12. Phasing
Rough order; each phase is independently shippable.
1. Image + ripgrep. Ship potpie/agent-sandbox Docker image,rg
register as the daytona snapshot default. Verify , git, jqSandboxClient
present in all backends.
2. Client surface. over existing service. Helpersread_file
(, write_file, list_dir, search, commit, push).(user, project)
Unit tests. No callers yet.
3. One sandbox per for the daytona backend.ToolService
Worktree-per-branch inside it. New e2e test that two workspace
requests with different branches share the same Daytona sandbox.
4. Sandbox-backed agent tools registered in . Oldcode_gen_agent
tools still present.
5. Migrate to the new tools. Update its prompt.ParseHelper
Smoke test.
6. Migrate parsing. uses SandboxClient via local-fscode_gen
backend. Re-run parsing tests.
7. Migrate remaining agents. One per PR; same pattern as .CodeChangesManager
8. Delete . Including its Redis schema,.repos
lifecycle plumbing, and the staging tool family.
9. Move glue into the local adapter. Deleteapp/modules/repo_manager/
. Migrate .meta JSON state on first
read.
10. Durable store + Redis locks. When we go multi-worker.
11. Background eviction. Separate from the request path.
Phases 1–3 are foundational and can land concurrently. 4–7 are the
visible migration. 8–9 are the cleanup. 10–11 are scaling work, not
correctness work.
---
13. Open questions
These are real choices the implementer should make explicitly, not pretend
the plan answered:
- One image or two. Slim vs full. Cold start vs feature coverage.
Daytona snapshot pull time matters here — measure it.
- Per-user vs shared RepoCache. Sharing saves disk and clone time;
separating is simpler for auth and quota. Default to per-user; design
the key so we can flip later.
- Where the SandboxRunContext contextvar lives. Closest analogue
is _code_changes_manager_ctx incode_changes_manager/context.py
. Same pattern, different module.WorkspaceMode
- Whether to keep . ANALYSIS / EDIT / TASK currentlyexec_stream
drive branch naming. Once parsing goes through us with mode=ANALYSIS
and agents with mode=EDIT, we have one more user (TASK?) — or we
collapse to two (read vs write). Simpler is better; collapse if the
test suite still passes.
- Streaming exec. exists in the port but isn't used.code_provider_create_pr
Promote it once an agent flow needs progress (long test runs, builds).
- PR creation. is a tool today and livessandbox_open_pr
outside the sandbox. Either fold it into (clean,[email protected]
but ties auth to the sandbox lib), or keep it separate (clean
layering, slightly worse ergonomics). Lean toward separate.
- Multi-tenancy at the Daytona level. One Daytona org per potpie
deployment? Per user? The dev stack has one user;
production needs a story. Out of scope here, but the sandbox client's
config shape should not preclude per-user Daytona credentials.
---
14. What this replaces, in one table
| Concept | Today | After |
| ---------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- |
| Bare repo + worktree on disk | app/modules/repo_manager/ | app/src/sandbox/.../adapters/outbound/local/ |CodeChangesManager
| In-flight file edits | (Redis, 24h TTL) | The worktree + git |code_changes_manager/tools.py
| Edit-staging tools (~20) | | A dozen sandbox_* tools, mostly just file/git ops |ParseHelper.clone_or_copy_repository
| Repo materialization for parsing | → RepoManager | SandboxClient.get_workspace(mode=ANALYSIS) |get_or_create_edits_worktree_path
| Repo materialization for agents | Tool internals call per call | One SandboxClient.get_workspace per run, cached on ctx |(user, repo, conversation)
| Branch concurrency | One worktree per on disk | Many worktrees per Daytona sandbox; one cache per repo |sync_helper.py
| Clone auth | (GitHub App → OAuth → env) | Same code, lives in the local adapter |.repos
| directory | The blessed location | One adapter's storage; equivalent in Daytona is a sandbox |prepare_for_parsing
| Eviction | Inline during | Background task per adapter |LockManager
| Locks | None at app layer; OS file locks for git | per workspace; pluggable Redis impl |Project
| Durable store | DB tables for ; JSON in .meta/ | SandboxStore (Postgres adapter when multi-worker) |
| Tool wiring | Hardcoded allow-lists + emerging registry | Registry-only; sandbox tools as a group |
---
If something in here looks underspecified, that's by design — the plan
should make the call points obvious without freezing the implementation.
The right next step is phase 1: build the image, snapshot it, and prove
rg is available in a Daytona sandbox end-to-end. Everything else
follows.
---
Vscode Extension Debug Handoff
VS Code Extension Debug/DAP Handoff
Date: 2026-05-25
Context
The debug agent can reach the local workspace over the Socket.IO tunnel, but C/C++
debugging currently fails or degrades in two ways:
1. Terminal commands such as gcc -g ... and mkdir ... can complete locally whiletool_response
the backend waits until the RPC timeout because no is observed.get_workspace_debug_context
2. reports debug_adapters: ["python", "node"] evenlldb-dap
on a macOS machine where LLDB and are installed. This blocks C/C++
debug session setup from the agent's point of view.
This is not expected to be a local network or WebSocket reliability problem. The
backend timeout means: tool_call was emitted, but no matching tool_response
arrived on Redis pub/sub before the timeout.
Latest isolated harness verification after the extension-side terminal fix:
- /api/terminal/execute for cc -g -o add_numbers add_numbers.c returned insuccess=true
roughly 400ms with , exit_code=0, empty stdout/stderr, andtimed_out=false
./api/workspace/debug-context
- returned C/C++-relevant adapters:["python", "node", "lldb", "lldb-dap"]
./api/debug/start-session
- with lldb-dap succeeded and returned a session id./api/debug/set-breakpoints
- at add_numbers.c:4 returned a verified/api/debug/snapshot
breakpoint.
- after continue still failed withCannot take snapshot: session is running, expected paused
.
That harness run was a smoke test, not a full DAP tool matrix. It did not cover
step-over, step-into, step-out, evaluate, list-sessions, or a cleanstop-session path.
Backend Contract
Socket event from backend to extension:
{
"correlation_id": "<uuid>",
"endpoint": "/api/terminal/execute",
"payload": {},
"timeout": 35.0
}The extension must always respond with:
{
"correlation_id": "<same uuid>",
"success": true,
"result": {}
}or:
{
"correlation_id": "<same uuid>",
"success": false,
"error": "..."
}Important backend routes used by the debug agent:
| Endpoint | Purpose |
| --- | --- |
| /api/workspace/debug-context | Return launch configs, available debug adapters, inferred commands |/api/terminal/execute
| | Run shell commands in sync or async mode |/api/terminal/sessions/{session_id}/output
| | Poll async terminal output |/api/terminal/sessions/{session_id}/signal
| | Stop async terminal process |/api/debug/start-session
| | Start DAP debug session |/api/debug/set-breakpoints
| | Set breakpoints |/api/debug/snapshot
| | Capture stack/locals/expressions |/api/debug/list-sessions
| | List debug sessions |/api/debug/stop-session
| | Stop debug session |
Required Extension Changes
1. Always Emit tool_response
Every extension route handler must emit a response for every received
correlation_id, including:
- success with empty stdout
- non-zero exit
- thrown exception
- timeout
- unknown route
- debug adapter launch failure
This should be implemented with a top-level try/catch/finally around dispatch so
no handler can accidentally leave the backend waiting.
2. Fix Terminal Completion For Silent Commands
The terminal executor must not infer completion from visible terminal output.
Commands like these are valid and often produce no stdout:
gcc -g -o add_numbers add_numbers.c
mkdir -p .vscode
trueThe user-facing contract is that terminal commands should be visible in a VS Code
integrated terminal by default. A hidden child_process result is acceptable only
for an explicitly hidden/internal mode; it is not acceptable for normal debug-agent
terminal commands because users must be able to see what the agent ran.
Recommended behavior:
- Open or reuse a named integrated terminal such as Potpie.
- Show the command in that terminal before execution.
- Capture stdout, stderr, exit code, duration, and timeout for the RPC response.
- Append an internal sentinel to detect completion reliably when the command is
silent.
- Keep the terminal visible unless the payload explicitly requests hidden
execution.
Implementation options:
- If using an integrated terminal/PTY, append an explicit sentinel that includes
exit code, then parse that sentinel, e.g. printf "\n__POTPIE_EXIT:$?__\n".child_process.spawn
- If using / execFile, wire it only to an explicit hidden
mode or mirror the command/output into the visible terminal.
Expected sync result shape:
{
"success": true,
"command": "gcc -g -o add_numbers add_numbers.c",
"output": "",
"error": "",
"exit_code": 0,
"duration_ms": 1234
}3. Return Accurate debug_adapters
debug_adapters should represent debug adapter types that the extension can
actually launch in the current VS Code/Cursor session.
For C/C++ on macOS, include at least one of:
- lldb when CodeLLDB (vadimcn.vscode-lldb) is availablecppdbg
- when Microsoft C/C++ (ms-vscode.cpptools) is availablelldb-dap
- or another explicit adapter id only if the extension start-session
handler can launch it directly
Do not infer this only from binaries on disk. /usr/bin/lldb and/Library/Developer/CommandLineTools/usr/bin/lldb-dap prove the machine has LLDB,
but they do not prove VS Code has a registered adapter type unless the extension can
use them.
If the extension can launch /Library/Developer/CommandLineTools/usr/bin/lldb-dap
directly without a marketplace extension, return a capability that makes that
explicit, for example:
{
"debug_adapters": ["python", "node", "lldb-dap"],
"native_debuggers": {
"lldb": "/usr/bin/lldb",
"lldb_dap": "/Library/Developer/CommandLineTools/usr/bin/lldb-dap"
}
}4. Support C/C++ Launch Configs
The extension should accept launch configs from .vscode/launch.json and start
sessions for C/C++ configs such as:
{
"type": "cppdbg",
"request": "launch",
"name": "Debug add_numbers",
"program": "/Users/deepesh/work/valkey/add_numbers",
"cwd": "/Users/deepesh/work/valkey",
"MIMode": "lldb"
}and/or CodeLLDB style:
{
"type": "lldb",
"request": "launch",
"name": "Debug add_numbers",
"program": "/Users/deepesh/work/valkey/add_numbers",
"cwd": "/Users/deepesh/work/valkey"
}If an adapter is missing, return a structured failure response instead of timing
out:
{
"success": false,
"error": "debug_adapter_unavailable",
"message": "No registered debug adapter for type 'cppdbg'. Install ms-vscode.cpptools or use lldb."
}5. Add Correlation-ID Tracing
Log these lifecycle points with correlation_id, endpoint, and workspace_id:
- received tool_calltool_response
- selected handler
- handler started
- handler completed
- emitted
- handler exception
- handler timeout
This is the fastest way to distinguish:
- backend emitted to stale socket id
- extension received but handler did not respond
- extension responded but socket server did not publish to Redis
- backend worker did not receive Redis pub/sub message
Acceptance Checks
With the extension connected to the workspace:
1. execute_terminal_command("true") returns exit code 0 without timeout.execute_terminal_command("mkdir -p .vscode")
2. returns exit code 0 without timeout.execute_terminal_command("gcc -g -o add_numbers add_numbers.c")
3. returns exitget_workspace_debug_context
code 0 without timeout, even with empty stdout.
4. includes the C/C++ adapter that the extension canlldb
actually launch (, cppdbg, or lldb-dap).start_debug_session
5. for the compiled add_numbers binary returns a session idset_breakpoints
or a structured adapter-unavailable error; it must not time out silently.
6. at add_numbers.c:4 and take_debug_snapshot return locals
when the program pauses.
7. User-facing terminal commands visibly open or reuse an integrated terminal and
show the command being run; the RPC response must not be produced only by a
hidden process in normal mode.
Backend Follow-Up Needed
The backend currently models start_debug_session.language as:
Literal["python", "node", "go", "unknown"]To make C/C++ first-class, backend should add c, cpp`, or a direct launch-config
type field and pass through adapter-specific launch configs without forcing them
through language-only routing.
---