Skip to content

Commit 051e634

Browse files
authored
feat(workspaces): name sandboxd sessions and Daytona sandboxes (#120)
## Problem When a run had no ref, the provider always named its session. An application that keys a sandbox on its own record (a conversation, a user) therefore had to store each ref before a second run could find the same files, and two first runs of one conversation opened two sessions. AgenticOS needs this to move its sandboxd and Daytona workspaces onto this release (vstorm-co/agenticos#2003, decision 1: "who names a session"). Before 0.2.30, `RemoteSandbox(session_id=..., reuse=True)` did exactly this. ## What changes - **`SandboxdWorkspace(session_name=...)`**: with no ref, the first operation sends `session_id=<name>` with `reuse`. The service opens the session or attaches to the open one or to its kept files. A `409` ("Session is opening", from a concurrent open of the same name) is retried until `request_timeout`, so both runs end up in one session. Names the service would reject fail at construction. - **`DaytonaWorkspace(sandbox_name=...)`**: looks the sandbox up by name, starting a stopped one, or creates it with `name=` added to a copy of `create_params`. A `DaytonaConflictError` from a concurrent create attaches instead. The ref carries the name, which `get` accepts. - **Both:** a ref stays attach-only, so a session purged in the meantime surfaces as `WorkspaceUnavailableError` rather than an empty directory. `get_workspace` leaves a ref naming another session to another capability, as `DockerWorkspace(container_name=...)` does since 0.2.31. - Docs (`remote.md`, `daytona.md`) and a CHANGELOG `[Unreleased]` entry. ## Verification - `make test`: 1334 passed, 100% coverage - `make lint`, `make typecheck` (pyright, 0 errors), `make typecheck-mypy`: clean - sandboxd: tested against the real service in-process (uvicorn over HTTP). Covered: opening under the name, a second client in the same session, kept files found by name after the session closed, a purged session behind a ref being unavailable, name validation, routing, the 409 retry, and a 409 that never clears. - Daytona: the SDK fake learns names. Covered: create under the name with the caller's params left untouched, attach to and start an existing one, a conflict, a destroyed one, a ref staying attach-only, routing. ## Limitations Daytona is not checked against a live account here, the same as the rest of `DaytonaWorkspace`.
1 parent 7e9e5bb commit 051e634

7 files changed

Lines changed: 298 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`SandboxdWorkspace(session_name=...)` and `DaytonaWorkspace(sandbox_name=...)`.**
13+
The workspace is named by whoever configures it rather than by the provider: a
14+
run with no ref opens it under the name, or attaches when it exists, so an
15+
application can key a session on its own record - a conversation, a user -
16+
without storing a ref first. `DockerWorkspace(container_name=...)` did this for
17+
Docker in 0.2.31. Two clients opening one name at once end up in one session.
18+
A ref is still attach-only, so a run that knows the session existed hears it is
19+
gone instead of starting over in an empty one, and a ref naming anything else
20+
is left to another capability.
21+
1022
## [0.2.31] - 2026-10-05
1123

1224
### Added

‎docs/concepts/daytona.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ attaches to it, starting it when Daytona stopped or archived it; one that is des
3737
gone fails with `WorkspaceUnavailableError`. `destroy(ref)` deletes it. Daytona's own
3838
auto-stop and auto-delete still apply.
3939

40+
`sandbox_name` names the sandbox instead: a run with no ref creates it under that name, or
41+
attaches to the sandbox that already has it, so an application can key a sandbox on its
42+
own record without storing a ref first. Its ref carries the name. Two clients creating
43+
the same name at once end up in one sandbox. A ref is still attach-only, so a sandbox
44+
deleted meanwhile is `WorkspaceUnavailableError` rather than a new one.
45+
46+
```python
47+
sandboxes = DaytonaWorkspace(config=config, sandbox_name=f"conv-{conversation_id}")
48+
```
49+
4050
## How a command runs
4151

4252
As a synchronous session command, the one Daytona API that reports stdout and stderr apart

‎docs/concepts/remote.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,27 @@ The service generates session ids, so they are unguessable; an application namin
337337
its own with `reuse` should derive them from a UUID it generated, never from a
338338
sequential id or user input, and keep them inside 64 characters.
339339

340+
### A session named by you
341+
342+
`SandboxdWorkspace(session_name=...)` is that from the client: a run with no
343+
ref opens the session under the name, or attaches when it is open or its files
344+
are kept, so the application can key the session on its own record - a
345+
conversation, a user - without storing a ref first:
346+
347+
```python
348+
sandbox = SandboxdWorkspace(
349+
service_url="http://sandboxd:8080",
350+
token=token,
351+
session_name=f"conv-{conversation_id}",
352+
)
353+
```
354+
355+
Two runs opening the same name at once both end up in one session: the service
356+
answers the second with `409` while the first is still opening it, and the
357+
client asks again. A ref is still attach-only, so a run continuing a
358+
conversation whose session was purged gets `WorkspaceUnavailableError` rather
359+
than an empty directory - hand it the ref when you know the session existed.
360+
340361
## Making it fast on a small host
341362

342363
Four settings, and on a 4 GB box each one is worth more than it sounds.

‎src/pydantic_ai_backends/workspaces/_daytona.py‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ class DaytonaWorkspaceBackend(WorkspaceBackend, SupportsCommands):
9898
env: Variables every command gets, under any a call passes.
9999
client: An `AsyncDaytona` to share, owned and closed by the caller;
100100
without one each operation opens and closes its own.
101+
sandbox_name: A sandbox name chosen by whoever configures the workspace
102+
rather than by Daytona: with no ref, the first operation creates the
103+
sandbox under it, or attaches to the one that already has it. A ref
104+
is still attach-only, so a caller that knows the sandbox existed
105+
learns that it is gone instead of starting over in a new one.
101106
"""
102107

103108
def __init__(
@@ -108,9 +113,11 @@ def __init__(
108113
ref: WorkspaceRef | None = None,
109114
env: Mapping[str, str] | None = None,
110115
client: AsyncDaytona | None = None,
116+
sandbox_name: str | None = None,
111117
) -> None:
112118
if ref is not None and ref.provider != DAYTONA_PROVIDER:
113119
raise ValueError(f"expected a {DAYTONA_PROVIDER!r} workspace ref, got {ref.provider!r}")
120+
self._sandbox_name = sandbox_name
114121
self._config = config
115122
self._create_params = create_params
116123
self._ref = ref
@@ -137,33 +144,44 @@ async def _daytona(self) -> AsyncIterator[AsyncDaytona]:
137144

138145
async def _sandbox(self, client: AsyncDaytona) -> AsyncSandbox:
139146
"""The sandbox, created or attached under the lock, with a session open in it."""
140-
daytona = load("daytona", purpose="DaytonaWorkspace")
141147
async with self._lock:
142-
if self._ref is None:
148+
if self._ref is not None:
149+
sandbox = await _attach(client, self._ref.id)
150+
elif self._sandbox_name is not None:
151+
sandbox = await self._open_named(client, self._sandbox_name)
152+
else:
143153
# Shielded so a caller cancelled mid-create still leaves the ref
144154
# of a sandbox Daytona has already made.
145155
with anyio.CancelScope(shield=True):
146156
sandbox = await client.create(self._create_params)
147157
self._ref = WorkspaceRef(provider=DAYTONA_PROVIDER, id=sandbox.id)
148-
else:
149-
try:
150-
sandbox = await client.get(self._ref.id)
151-
except daytona.DaytonaNotFoundError as error:
152-
raise WorkspaceUnavailableError(
153-
f"Daytona sandbox {self._ref.id!r} no longer exists"
154-
) from error
155-
state = _state(sandbox)
156-
if state in GONE_STATES:
157-
raise WorkspaceUnavailableError(f"Daytona sandbox {self._ref.id!r} is {state}")
158-
if state in RESUMABLE_STATES:
159-
await sandbox.start()
160158
if self._working_dir is None:
161159
self._working_dir = await sandbox.get_work_dir()
162160
if not self._session_open:
163161
await sandbox.process.create_session(self._session_id)
164162
self._session_open = True
165163
return sandbox
166164

165+
async def _open_named(self, client: AsyncDaytona, name: str) -> AsyncSandbox:
166+
"""The sandbox called `name`: the existing one, or a new one created under it."""
167+
daytona = load("daytona", purpose="DaytonaWorkspace")
168+
with anyio.CancelScope(shield=True):
169+
try:
170+
sandbox = await _attach(client, name)
171+
except WorkspaceUnavailableError as gone:
172+
if not isinstance(gone.__cause__, daytona.DaytonaNotFoundError):
173+
raise
174+
params = self._create_params or daytona.CreateSandboxFromSnapshotParams()
175+
try:
176+
sandbox = await client.create(params.model_copy(update={"name": name}))
177+
except daytona.DaytonaConflictError:
178+
# Another client created it between our lookup and our create.
179+
sandbox = await _attach(client, name)
180+
# The name, not the id: it is what a later client knows to look for,
181+
# and `get` takes either.
182+
self._ref = WorkspaceRef(provider=DAYTONA_PROVIDER, id=name)
183+
return sandbox
184+
167185
async def working_dir(self) -> str:
168186
"""The sandbox's working directory, as Daytona reports it."""
169187
async with self._daytona() as client:
@@ -264,6 +282,27 @@ async def purge(self) -> None:
264282
await client.delete(sandbox)
265283

266284

285+
async def _attach(client: AsyncDaytona, id_or_name: str) -> AsyncSandbox:
286+
"""The existing sandbox `id_or_name` names, started when it was stopped or archived.
287+
288+
Raises:
289+
WorkspaceUnavailableError: It does not exist, or is gone for good.
290+
"""
291+
daytona = load("daytona", purpose="DaytonaWorkspace")
292+
try:
293+
sandbox = await client.get(id_or_name)
294+
except daytona.DaytonaNotFoundError as error:
295+
raise WorkspaceUnavailableError(
296+
f"Daytona sandbox {id_or_name!r} no longer exists"
297+
) from error
298+
state = _state(sandbox)
299+
if state in GONE_STATES:
300+
raise WorkspaceUnavailableError(f"Daytona sandbox {id_or_name!r} is {state}")
301+
if state in RESUMABLE_STATES:
302+
await sandbox.start()
303+
return sandbox
304+
305+
267306
@dataclass(kw_only=True)
268307
class DaytonaWorkspace(AbstractCapability[object]):
269308
"""Supply a Daytona sandbox as the run's workspace.
@@ -300,6 +339,14 @@ class DaytonaWorkspace(AbstractCapability[object]):
300339
client: AsyncDaytona | None = field(default=None, repr=False, compare=False)
301340
"""An `AsyncDaytona` to share across runs, owned and closed by the caller."""
302341

342+
sandbox_name: str | None = None
343+
"""One sandbox for every run, named by you: created on first use, attached after.
344+
345+
Without it each run without a ref gets a new sandbox and only the ref leads
346+
back to it. With it a later process finds the same sandbox by name. A ref
347+
naming another sandbox is left to another capability.
348+
"""
349+
303350
def __post_init__(self) -> None:
304351
if self.defer_loading:
305352
raise UserError(
@@ -315,6 +362,7 @@ def backend(self, ref: WorkspaceRef | None = None) -> DaytonaWorkspaceBackend:
315362
ref=ref,
316363
env=self.env,
317364
client=self.client,
365+
sandbox_name=self.sandbox_name,
318366
)
319367

320368
def get_workspace(
@@ -324,6 +372,8 @@ def get_workspace(
324372
del ctx
325373
if ref is not None and ref.provider != DAYTONA_PROVIDER:
326374
return None
375+
if ref is not None and self.sandbox_name is not None and ref.id != self.sandbox_name:
376+
return None
327377
return self.backend(ref)
328378

329379
async def destroy(self, ref: WorkspaceRef) -> None:

‎src/pydantic_ai_backends/workspaces/_sandboxd.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import contextlib
6+
import re
67
import uuid
78
from collections.abc import AsyncIterator, Mapping
89
from dataclasses import dataclass, field
@@ -45,6 +46,9 @@
4546
STOP_GRACE_SECONDS = 5.0
4647
"""Longest a cancelled command's stop request may hold up the cancellation."""
4748

49+
OPENING_RETRY_SECONDS = 0.2
50+
"""Pause before asking again for a named session another client is opening."""
51+
4852
_GONE = frozenset({404, 410})
4953
"""Statuses meaning the session or its sandbox no longer exists."""
5054

@@ -84,6 +88,12 @@ class SandboxdWorkspaceBackend(WorkspaceBackend, SupportsCommands):
8488
client: An `httpx.AsyncClient` to share. Owned by the caller, who
8589
closes it; without one each request uses a client of its own.
8690
request_timeout: Seconds for a request that runs no command.
91+
session_name: A session id chosen by whoever configures the workspace
92+
rather than by the service: with no ref, the first operation opens
93+
the session under it, or attaches when it is open or its files are
94+
kept. A ref is still attach-only, so a caller that knows the
95+
session existed learns that it is gone instead of starting over in
96+
an empty one.
8797
"""
8898

8999
def __init__(
@@ -98,9 +108,14 @@ def __init__(
98108
env: Mapping[str, str] | None = None,
99109
client: httpx.AsyncClient | None = None,
100110
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
111+
session_name: str | None = None,
101112
) -> None:
102113
if ref is not None and ref.provider != provider:
103114
raise ValueError(f"expected a {provider!r} workspace ref, got {ref.provider!r}")
115+
if session_name is not None and not re.fullmatch(wire.SESSION_ID_PATTERN, session_name):
116+
raise ValueError(
117+
f"session_name {session_name!r} is not a session id the service accepts"
118+
)
104119
self._service_url = service_url.rstrip("/")
105120
self._token = token
106121
self._ref = ref
@@ -110,6 +125,7 @@ def __init__(
110125
self._env = dict(env) if env else None
111126
self._client = client
112127
self._request_timeout = request_timeout
128+
self._session_name = session_name
113129
self._session: _Session | None = None
114130
self._working_dir: str | None = None
115131
self._lock = anyio.Lock()
@@ -145,15 +161,26 @@ async def _post(
145161

146162
async def _open(self) -> _Session:
147163
"""Open or attach to the session, then learn the service's command ceiling."""
164+
named = self._ref is None and self._session_name is not None
148165
request = wire.CreateSessionRequest(
149-
session_id=None if self._ref is None else self._ref.id,
166+
session_id=self._session_name if self._ref is None else self._ref.id,
150167
runtime=self._runtime,
151168
tenant=self._tenant,
169+
reuse=named,
152170
attach=self._ref is not None,
153171
)
154172
response = await self._post(
155173
"/sessions", request, token=self._token, timeout=self._request_timeout
156174
)
175+
# With `reuse` a 409 means another client is opening the same name right
176+
# now - two first runs of one conversation, say. It is theirs a moment
177+
# later, so ask again rather than fail one of them.
178+
give_up = anyio.current_time() + self._request_timeout
179+
while named and response.status_code == 409 and anyio.current_time() < give_up:
180+
await anyio.sleep(OPENING_RETRY_SECONDS)
181+
response = await self._post(
182+
"/sessions", request, token=self._token, timeout=self._request_timeout
183+
)
157184
if self._ref is not None and response.status_code == 404:
158185
raise WorkspaceUnavailableError(f"sandboxd session {self._ref.id!r} no longer exists")
159186
response.raise_for_status()
@@ -350,6 +377,15 @@ class SandboxdWorkspace(AbstractCapability[object]):
350377
client: httpx.AsyncClient | None = field(default=None, repr=False, compare=False)
351378
"""An `httpx.AsyncClient` to share across runs, owned and closed by the caller."""
352379

380+
session_name: str | None = None
381+
"""One session for every run, named by you: opened on first use, attached after.
382+
383+
Without it each run without a ref gets a new session and only the ref leads
384+
back to it. With it a later process finds the same session - or the files the
385+
service kept after reaping it - by name. A ref naming another session is left
386+
to another capability.
387+
"""
388+
353389
def __post_init__(self) -> None:
354390
if self.defer_loading:
355391
raise UserError(
@@ -368,6 +404,7 @@ def backend(self, ref: WorkspaceRef | None = None) -> SandboxdWorkspaceBackend:
368404
tenant=self.tenant,
369405
env=self.env,
370406
client=self.client,
407+
session_name=self.session_name,
371408
)
372409

373410
def get_workspace(
@@ -377,6 +414,8 @@ def get_workspace(
377414
del ctx
378415
if ref is not None and ref.provider != self.provider:
379416
return None
417+
if ref is not None and self.session_name is not None and ref.id != self.session_name:
418+
return None
380419
return self.backend(ref)
381420

382421
async def destroy(self, ref: WorkspaceRef) -> None:

0 commit comments

Comments
 (0)