Sandbox core setup
Context
Section titled “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 wires
LocalGitWorkspaceProvider + 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.
- 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
Section titled “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.pypath immediately. - Treating snapshots as the only persistence mechanism. Git worktrees and volumes are first-class persistence.
Core Model
Section titled “Core Model”RepoCache
Section titled “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
Section titled “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
Section titled “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)
Section titled “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—WorkspaceErrorfamily (WorkspaceNotFound,RepoCacheUnavailable,RepoAuthFailed,InvalidWorkspacePath, …) andRuntimeErrorBasefamily (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)
SandboxServiceownsget_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)
SandboxClientis the public façade. Helpers:read_file,write_file,list_dir,search,status,diff,commit,push. Local-fs fast paths whenhandle.local_path is not None; exec-based fallbacks otherwise.
Bootstrap (app/src/sandbox/sandbox/bootstrap/)
settings.pyreadsSANDBOX_WORKSPACE_PROVIDER/SANDBOX_RUNTIME_PROVIDER/SANDBOX_REPOS_BASE_PATH/SANDBOX_METADATA_PATH.container.py—build_sandbox_containerselects 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—LocalSubprocessRuntimeProviderfor local exec.adapters/outbound/daytona/provider.py—DaytonaWorkspaceProviderandDaytonaRuntimeProvider. 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-wideSandboxClientaccessor (get_sandbox_client) andresolve_workspace(...)helper.context.py— contextvars (user_id,conversation_id,branch,auth_token) the agent harness sets at run start.tools.pyexportscreate_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 aWorkspaceHandleper call.
Known Gaps (post-revamp)
Section titled “Known Gaps (post-revamp)”Findings from the architectural audit; tracked by the Implementation Roadmap below.
-
Two parallel local providers, one is dead code. (resolved)
LocalGitWorkspaceProvideris the only local sandbox provider; theRepoManagerWorkspaceProviderbridge has been removed. Parsing still usesRepoManagerfor its own clone/eviction; sandbox traffic does not. -
Dual unsynchronized persistence. (resolved for sandbox traffic) With the bridge gone, sandbox state lives only in
JsonSandboxStore. Parsing’sRepoManagermetadata at.repos/.meta/<owner>/<repo>/branch__commit.jsonis independent and no longer needs to reconcile. -
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 noEvictionPolicyport and no eviction logic of its own. -
Parsing bypasses the sandbox abstraction.
parsing_service.py:298callsparse_helper.clone_or_copy_repositorywhich goes directly toRepoManager. There is a feature-flaggedSandboxClientpath (SANDBOX_PARSING_ENABLED), but it is not the default and does not persist aWorkspacepast the call. Afterupdate_project_status(..., READY)atparsing_service.py:575no workspace record exists in the sandbox store. -
No first-class
RepoCacheentity.RepoCacheis declared inmodels.py:115butWorkspace.repo_cache_idis alwaysNone. There is noRepoCacheProviderport; the bare-repo concept is implicit inside each adapter. -
WorkspaceModeoverloads four concerns: read-only vs writable, branch creation, sharing, and keying. Branch-creation logic is duplicated acrossLocalGitWorkspaceProvider._create_workspace_syncanddaytona/provider.py:_ensure_worktree. Capabilities should be explicit on the workspace, derived from mode at construction. -
Daytona adapter leaks SDK types.
Any-typed sandbox handles (_client_factory,_sandboxes) flow through the application layer.DaytonaRuntimeProvider.execreaches intosandbox.process.execdirectly. Worktree paths use onlybranch_name(vs. local’s<user>_<scope>_<branch>), which weakens isolation guarantees. -
No git-platform port. PR creation is not represented in the sandbox layer.
commitandpushexist onSandboxClient, but creating a PR has to bypass the sandbox abstraction. -
Tool surface uses contextvars instead of explicit dependencies.
tool_functions._resolvereadsuser_idfrom a contextvar and looks uprepo_namevia DB. The agent harness sets the contextvar atpydantic_agent.py:613-621. A toolset factory should take(client, run_context)explicitly. -
No eviction port / no per-conversation cleanup hook.
SandboxStorelackslist_workspaces_by_repo,find_workspaces_for_eviction, etc. EDIT-mode worktrees are only reaped byRepoManager’s age-based eviction; norelease_session(conversation_id)exists.
Layered Architecture
Section titled “Layered Architecture”Agent / Tool layer | vSandboxService - 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
Section titled “Public Service API”Tools and agents should call SandboxService, not backend adapters directly.
from dataclasses import dataclassfrom 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
Section titled “Runtime Port”The provider port should be about compute, not repo cloning policy.
from dataclasses import dataclassfrom typing import AsyncIterator, Literal, Optional, Protocol
RuntimeId = strRuntimeState = Literal["starting", "running", "stopped", "error"]
@dataclass(frozen=True)class Mount: source: str target: str writable: bool
@dataclass(frozen=True)class ResourceHints: cpu: Optional[float] = None memory_mb: Optional[int] = None disk_gb: Optional[int] = None
@dataclass(frozen=True)class RuntimeSpec: image: str workdir: str mounts: list[Mount] env: dict[str, str] resources: Optional[ResourceHints] = None network: Literal["none", "limited", "default"] = "limited" placement: Optional[dict] = None labels: Optional[dict[str, str]] = None
@dataclass(frozen=True)class ExecRequest: cmd: list[str] cwd: Optional[str] = None env: Optional[dict[str, str]] = None timeout_s: Optional[int] = None stdin: Optional[bytes] = None max_output_bytes: Optional[int] = None
@dataclass(frozen=True)class ExecResult: exit_code: int stdout: bytes stderr: bytes timed_out: bool = False truncated: bool = False
@dataclass(frozen=True)class ExecChunk: stream: Literal["stdout", "stderr"] data: bytes
class SandboxRuntime(Protocol): id: RuntimeId state: RuntimeState
async def start(self) -> None: ... async def stop(self) -> None: ... async def exec(self, req: ExecRequest) -> ExecResult: ... async def exec_stream(self, req: ExecRequest) -> AsyncIterator[ExecChunk]: ...
class RuntimeProvider(Protocol): kind: Literal["local_readonly", "docker", "daytona", "e2b"] capabilities: "RuntimeCapabilities"
async def create(self, spec: RuntimeSpec) -> SandboxRuntime: ... async def get(self, id: RuntimeId) -> SandboxRuntime: ... async def list(self, labels: Optional[dict[str, str]] = None) -> list["RuntimeSummary"]: ... async def destroy(self, id: RuntimeId) -> None: ...Optional Capabilities
Section titled “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 | Noneprovider.preview_urls: PreviewURLCapability | Noneprovider.sessions: SessionCapability | NoneStorage Model
Section titled “Storage Model”Persist this in Postgres, even if the first implementation only supports local filesystem paths.
sandbox_repo_caches
Section titled “sandbox_repo_caches”One row per durable repo mirror.
Recommended columns:
idrepo_namerepo_urlproviderprovider_hostlocal_pathauth_scope_hashlast_fetched_atlast_used_atsize_bytesstatecreated_atupdated_at
Local implementation can mirror existing .repos/.meta data, then migrate more
fully later.
sandbox_workspaces
Section titled “sandbox_workspaces”One row per durable worktree/workspace.
Recommended columns:
idrepo_cache_idproject_iduser_idmodeconversation_idtask_idbase_refbranch_namelocal_pathstatedirtypinned_untillast_used_atsize_bytescreated_atupdated_at
Suggested uniqueness:
- Analysis workspace:
(user_id, project_id, base_ref, mode)wheremode = "analysis". - Edit workspace:
(user_id, project_id, conversation_id)wheremode = "edit". - Task workspace:
(user_id, project_id, task_id)wheremode = "task".
sandbox_runtimes
Section titled “sandbox_runtimes”One row per runtime attachment.
Recommended columns:
idworkspace_idbackend_kindbackend_runtime_idimagestatelast_started_atlast_used_atexpires_atcreated_atupdated_at
Runtime records can be deleted aggressively. Workspace records should survive runtime destruction.
Workspace Lifecycle
Section titled “Workspace Lifecycle”Analysis Flow
Section titled “Analysis Flow”Use this for read-only code search and exploration.
- Resolve
project_idto repo details. - Ensure repo cache exists using
RepoManager.ensure_bare_repoorprepare_for_parsing. - Get or create a clean worktree for the requested branch/commit.
- Attach a read-only runtime when possible.
- 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
Section titled “Edit Flow”Use this for feature work, bug fixes, tests, and PR creation.
- Resolve
project_idto repo details. - Ensure repo cache exists.
- Create or reuse an edit workspace keyed by
conversation_id. - Create branch
agent/edits-{conversation_id}from the project base branch. - Attach a write-capable runtime with the workspace mounted writable.
- Run commands and edits inside that runtime.
- 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
Section titled “Task Flow”Use this for multi-agent or background work where one conversation may spawn multiple independent implementation attempts.
- Key workspace by
task_id. - Create branch
agent/task-{task_id}or a child branch from the edit branch. - Keep each task workspace isolated.
- Merge/cherry-pick/diff back into the conversation edit workspace if accepted.
Eviction Policy
Section titled “Eviction Policy”Eviction should be tiered. Compute is cheap to recreate; repo clones are expensive.
Runtime Eviction
Section titled “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
Section titled “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:
- Clean analysis workspaces older than TTL.
- Clean task workspaces older than TTL.
- Clean edit workspaces older than TTL.
- Dirty unpinned task workspaces under disk pressure.
- 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_blockedand alert/admin-log.
Repo Cache Eviction
Section titled “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
Section titled “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
Section titled “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
limitedornone; 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
Section titled “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.shbrings the Daytona compose stack up. The script layersscripts/daytona-overrides/docker-compose.override.yamlon 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 whitelistshttp://localhost:3010as an OIDC redirect URI. SetDAYTONA_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 bundleddev@daytona.io / passworduser, sets a default region if needed, writesapp/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
- Daytona dashboard:
-
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 withDAYTONA_REPO_PATH=/path/to/daytona. -
Do not pass
--project-directoryto 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.shdeletes only sandboxes labelledmanaged-by=potpie. Pass--stackto also bring the compose stack down with-v. -
The bundled
daytonaio/sandbox:0.5.0-slimsnapshot does not ship thegitCLI. The Daytona adapter handles this by using the toolboxgit.clone/git.create_branch/git.checkout_branchendpoints and verifying outcomes by reading.git/HEADdirectly (the toolbox SDK occasionally raisesDaytonaValidationError("...: ")on operations that actually succeeded). -
Tests resolve
proxy.localhostto 127.0.0.1 in-process (seetests/e2e/conftest.py) so neither sudo nor thesetup-proxy-dns.shdnsmasq script is required for E2E runs. Long-lived shell access still needs the dnsmasq setup or an/etc/hostsentry. -
Adapter env vars (sourced from
.env.daytona.local):SANDBOX_WORKSPACE_PROVIDER=daytona,SANDBOX_RUNTIME_PROVIDER=daytona,DAYTONA_API_URL,DAYTONA_API_KEY, optionalDAYTONA_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
Section titled “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
Section titled “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
Section titled “P1 — Unify the local provider — DONE”Closes gaps 1, 2, 3.
EvictionPolicyport atdomain/ports/eviction.pywithEvictionResultvalue type;NoOpEvictionPolicydefault inadapters/outbound/memory/eviction.py.LocalGitWorkspaceProvideracceptseviction=kwarg, callsevict_if_neededon cache miss.LocalGitWorkspaceProvideris now the only local provider. TheRepoManagerWorkspaceProviderbridge and theSANDBOX_USE_CANONICAL_LOCALflag 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 theNoOpdefault is still a P1 follow-up; the canonical path runs unbounded until the policy is wired.
P2 — Promote RepoCache to first-class — DONE
Section titled “P2 — Promote RepoCache to first-class — DONE”Closes gap 5.
RepoCacheRequestandRepoCache(with stablekey) indomain/models.py.RepoCacheProviderport atdomain/ports/repos.py.RepoCacheStoremixin onSandboxStore; bothInMemorySandboxStoreandJsonSandboxStoreimplement it (rows survive restart).LocalRepoCacheProvideratadapters/outbound/local/repo_cache.pyowns bare-repo creation;LocalGitWorkspaceProviderdepends on the cache port and setsWorkspace.repo_cache_idon 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
Section titled “P3 — Capabilities split and acquire_session API — DONE”Closes gaps 6 and 10.
Capabilities(writable, isolated, persistent)value object;Capabilities.from_modeis the single source of truth.Workspace.capabilitiespopulated by every adapter (local, bridge, Daytona). Round-trips throughJsonSandboxStore.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_sessionsymmetric public API.
P4 — Daytona hardening — partial
Section titled “P4 — Daytona hardening — partial”Addresses gap 7 (correctness portion).
Done:
_validate_refindaytona/provider.pyrejects 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 as
Any 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
Section titled “P5 — Provision-on-parse — DONE”Closes gap 4.
SandboxClient.ensure_repo_cache(...)thin wrapper over the service.provision_repo_cachehelper atapp/modules/intelligence/tools/sandbox/client.py.parsing_service.pycalls_provision_repo_cache_safefrom BOTH READY transitions (eager-return short-circuit and the normal post-analyze_directoryexit). Failures are logged and swallowed — cache provisioning is an optimization, not a parsing prerequisite.
P6 — Tool surface refactor — DONE
Section titled “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 omitproject_id. Capability gating drops write tools when the handle is read-only (enforce_capabilities=Truedefault).- The legacy zero-arg
create_sandbox_tools()form keeps working — required for back-compat with the existing harness wiring atmulti_agent/agent_factory.pyandpydantic_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.capabilitiescarries the gating signal.- Contextvar machinery in
tools/sandbox/context.pyis 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
Section titled “P7 — GitPlatformProvider and PR tool — DONE”Closes gap 8.
GitPlatformProviderport atdomain/ports/git_platform.py;PullRequestRequest/PullRequestvalue objects indomain/models.py;PullRequestFailed/GitPlatformNotConfigurederrors.GitHubGitPlatformProviderbridge adapter atapp/modules/sandbox_repos/git_platform.pywraps the existingcode_provider.github.GitHubProviderso auth chain stays put.SandboxService.create_pull_request(request)enforces “platform configured” precondition;SandboxClient.create_pull_request(handle, ...)enforces “writable workspace” precondition.sandbox_pragent tool — included in the explicit toolset only when the harness passespr_repo_name=...; capability-gated on writable handles. Push the branch viasandbox_git pushfirst; the 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
SandboxClientcleanly; needs a small request-scoped factory before it can run in prod.
P8 — Postgres store and multi-worker locks — DEFERRED
Section titled “P8 — Postgres store and multi-worker locks — DEFERRED”Closes the implicit single-node assumption in JsonSandboxStore and
InMemoryLockManager. The schema sketch is already documented above
(sandbox_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)
Section titled “Outstanding follow-ups (small)”- P1 follow-up —
VolumeBasedEvictionPolicyreading fromSandboxStoreso canonical-local has bounded disk use. - P4 follow-up —
DaytonaApiProtocol port to removeAnytyping. - P6 follow-up — migrate harness callers
(
multi_agent/agent_factory.py,pydantic_agent.py) to the explicit-handle form, then delete the contextvar plumbing. - P7 follow-up — request-scoped
GitPlatformProviderfactory so the per-user auth chain works in production. - P8 — Postgres adapters once the DB story lands.
Open Questions
Section titled “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?