1010
1111 * `create()` → `InteractiveTmuxWorkspace.start_workspace(...)`
1212 (the running container exposes claude/codex/gemini panes
13- via the underlying `_handle ` for orchestration code that
13+ via `interactive_session() ` for orchestration code that
1414 wants direct access).
1515 * `destroy()` → `InteractiveTmuxWorkspace.stop()`
1616 * `execute()`, `read_file()`, `write_file()`, `file_exists()`
2020 uses, returning a proper `ExecuteResult` so call sites
2121 that already speak the protocol need no translation.
2222
23- The richer prompt round-trip API (send/await/capture) stays available on
24- the underlying workspace via `workspace._handle: InteractiveTmuxWorkspace`.
23+ The richer prompt round-trip API (send/await/capture) is exposed as a
24+ typed `agentic_isolation.providers.base.InteractiveSession` port via
25+ `provider.interactive_session(workspace)` (structurally satisfied by the
26+ underlying `InteractiveTmuxWorkspace` handle -- no wrapper class needed).
2527"""
2628
2729from __future__ import annotations
4244from agentic_isolation .providers .base import (
4345 BaseProvider ,
4446 ExecuteResult ,
47+ InteractiveSession ,
4548 Workspace ,
4649)
4750
@@ -181,11 +184,11 @@ class InteractiveTmuxProvider(BaseProvider):
181184 await provider.destroy(workspace)
182185
183186 To reach the underlying claude/codex/gemini panes for prompt
184- round-trips, pull the driver workspace off the handle :
185- ws: InteractiveTmuxWorkspace = workspace._handle
186- ws .send_message("claude", "hello")
187- result: AwaitResult = ws .await_completion("claude")
188- print(ws .capture_response("claude"))
187+ round-trips, get the typed `InteractiveSession` port :
188+ session = provider.interactive_session(workspace)
189+ session .send_message("claude", "hello")
190+ result = session .await_completion("claude")
191+ print(session .capture_response("claude"))
189192 """
190193
191194 def __init__ (
@@ -225,6 +228,18 @@ def __init__(
225228 self ._startup_timeout_s = startup_timeout_s
226229 self ._strict_startup = strict_startup
227230 self ._workspaces : dict [str , Workspace ] = {}
231+ # Guards ONLY mutations of the `self._workspaces` registry dict (the
232+ # sole shared state that needs it). It is deliberately NOT held
233+ # across the blocking `start_workspace`/`stop` driver calls: doing so
234+ # serialized every provision/teardown across all workspaces and made
235+ # a `destroy()` block behind an in-flight `create()`, defeating the
236+ # multi-agent concurrency this provider exists to enable. The
237+ # blocking calls run under `asyncio.to_thread` WITHOUT this lock, so
238+ # unrelated workspaces provision concurrently. (The previously feared
239+ # child-watcher race between two threaded `subprocess.run` calls was
240+ # never empirically reproduced, and the driver reaps its own child
241+ # via `os.waitpid` in a blocking `subprocess.run`, not through
242+ # asyncio's child watcher, so the race cannot occur here.)
228243 self ._lock = asyncio .Lock ()
229244
230245 @property
@@ -286,25 +301,32 @@ async def create(self, config: WorkspaceConfig) -> Workspace:
286301 workdir = config .working_dir or DEFAULT_CONTAINER_WORKDIR
287302 name = f"itws-{ uuid .uuid4 ().hex [:8 ]} "
288303
289- # The driver is blocking (subprocess + sleep loops). Calling it
290- # synchronously briefly blocks the event loop, but that's the
291- # right tradeoff: when an executor thread shells out via
292- # `subprocess.run` it races asyncio's child watcher and the docker
293- # run/exec calls become flaky (we saw 127 from docker exec right
294- # after a successful docker run -d). Holding the loop for the
295- # ~5s container-start window is acceptable; if a future caller
296- # needs concurrency, they can wrap `await provider.create(...)`
297- # in their own thread.
298- ws_handle : InteractiveTmuxWorkspace = driver .InteractiveTmuxWorkspace .start_workspace (
299- name = name ,
300- host_auth = host_auth ,
301- image = image ,
302- workdir = workdir ,
303- startup_timeout_s = self ._startup_timeout_s ,
304- strict_startup = self ._strict_startup ,
305- host_claude_dotjson = self ._default_host_claude_dotjson ,
306- claude_plugin_dirs = self ._default_claude_plugin_dirs ,
304+ # The driver is blocking (subprocess + sleep loops), so offload it via
305+ # `asyncio.to_thread` to keep the event loop free. It runs WITHOUT any
306+ # cross-workspace lock, so N concurrent `create()` calls provision in
307+ # parallel and a `destroy()` never waits behind an in-flight create.
308+ # `asyncio.shield` keeps the start running if THIS create() is
309+ # cancelled; the done callback then stops the late handle so nothing
310+ # leaks (see `_schedule_create_cancellation_cleanup`).
311+ loop = asyncio .get_running_loop ()
312+ start_task = asyncio .create_task (
313+ asyncio .to_thread (
314+ driver .InteractiveTmuxWorkspace .start_workspace ,
315+ name = name ,
316+ host_auth = host_auth ,
317+ image = image ,
318+ workdir = workdir ,
319+ startup_timeout_s = self ._startup_timeout_s ,
320+ strict_startup = self ._strict_startup ,
321+ host_claude_dotjson = self ._default_host_claude_dotjson ,
322+ claude_plugin_dirs = self ._default_claude_plugin_dirs ,
323+ )
307324 )
325+ try :
326+ ws_handle : InteractiveTmuxWorkspace = await asyncio .shield (start_task )
327+ except asyncio .CancelledError :
328+ self ._schedule_create_cancellation_cleanup (start_task , name , loop )
329+ raise
308330
309331 # The container is now running with throwaway claude/codex/gemini
310332 # credentials mounted. Any failure between here and a successful
@@ -325,6 +347,13 @@ async def create(self, config: WorkspaceConfig) -> Workspace:
325347 metadata = {
326348 "container" : ws_handle .container ,
327349 "workdir" : workdir ,
350+ # `workspace_dir` is what downstream artifact collection
351+ # (#225) reads to know where to copy files out of. This
352+ # provider is exec-based with no host bind-mount, so it is
353+ # the CONTAINER-side workspace path (identical to
354+ # `workdir`) -- consumers resolve container-relative
355+ # copies from it. It is intentionally NOT a host path.
356+ "workspace_dir" : workdir ,
328357 "enabled_agents" : list (ws_handle .enabled_agents ),
329358 "startup_status" : {a : r .to_dict () for a , r in ws_handle .startup_status .items ()},
330359 },
@@ -336,28 +365,88 @@ async def create(self, config: WorkspaceConfig) -> Workspace:
336365 except BaseException :
337366 # Best-effort teardown, then re-raise. BaseException so a
338367 # cancellation mid-setup also cleans up the credential-mounted
339- # container. stop() is synchronous and fast (<2s).
368+ # container. Shield the stop() so it runs to completion even if
369+ # THIS cleanup is itself cancelled (double-cancellation): the
370+ # inner `except BaseException` catches that second CancelledError
371+ # too, so no credential-seeded container can escape teardown.
340372 try :
341- ws_handle .stop ( )
342- except Exception :
373+ await asyncio . shield ( asyncio . to_thread ( ws_handle .stop ) )
374+ except BaseException :
343375 logger .warning (
344376 "failed to stop workspace %s during create() cleanup" ,
345377 name ,
346378 exc_info = True ,
347379 )
348380 raise
349381
382+ def _schedule_create_cancellation_cleanup (
383+ self ,
384+ start_task : asyncio .Task [Any ],
385+ name : str ,
386+ loop : asyncio .AbstractEventLoop ,
387+ ) -> None :
388+ """Stop a workspace that finishes starting after create() is cancelled.
389+
390+ `asyncio.to_thread()` cannot kill the underlying worker thread. If
391+ `create()` is cancelled while `start_workspace()` is still running,
392+ the thread may still return a live workspace handle later. Attach a
393+ done callback so that late handle is stopped and does not leak.
394+ """
395+
396+ def _on_started (task : asyncio .Task [Any ]) -> None :
397+ try :
398+ late_handle = task .result ()
399+ except BaseException :
400+ return
401+ loop .create_task (self ._stop_late_started_workspace (late_handle , name ))
402+
403+ start_task .add_done_callback (_on_started )
404+
405+ async def _stop_late_started_workspace (
406+ self ,
407+ ws_handle : InteractiveTmuxWorkspace ,
408+ name : str ,
409+ ) -> None :
410+ try :
411+ await asyncio .shield (asyncio .to_thread (ws_handle .stop ))
412+ except BaseException :
413+ logger .warning (
414+ "failed to stop workspace %s after create() cancellation" ,
415+ name ,
416+ exc_info = True ,
417+ )
418+
350419 async def destroy (self , workspace : Workspace ) -> None :
351420 ws_handle : InteractiveTmuxWorkspace | None = workspace ._handle
352421 async with self ._lock :
353422 self ._workspaces .pop (workspace .id , None )
354423 if ws_handle is None :
355424 return
356- # Sync for the same reason as create(): the driver shells out
357- # via subprocess.run and the asyncio child-watcher race causes
358- # `docker rm -f` to return spurious non-zero exit codes from
359- # an executor thread. Stop is fast (<2s).
360- ws_handle .stop ()
425+ # Offloaded via `asyncio.to_thread` because `stop()` shells out via
426+ # blocking `subprocess.run`. No cross-workspace lock is held, so a
427+ # destroy() never blocks behind an in-flight create() (or another
428+ # destroy) on an unrelated workspace.
429+ await asyncio .to_thread (ws_handle .stop )
430+
431+ def interactive_session (self , workspace : Workspace ) -> InteractiveSession | None :
432+ """Return the `InteractiveSession` port for `workspace`.
433+
434+ The underlying driver's `InteractiveTmuxWorkspace` handle already
435+ exposes `send_message`/`await_completion`/`capture_response` with
436+ signatures compatible with `InteractiveSession`; structural typing
437+ means it satisfies the protocol as-is, so no wrapper is needed.
438+ Returns `None` if the workspace has no live handle (e.g. already
439+ destroyed).
440+ """
441+ handle = workspace ._handle
442+ if handle is None :
443+ return None
444+ if not isinstance (handle , InteractiveSession ):
445+ raise TypeError (
446+ f"workspace._handle ({ type (handle )!r} ) does not satisfy "
447+ "InteractiveSession — expected an InteractiveTmuxWorkspace"
448+ )
449+ return handle
361450
362451 async def execute (
363452 self ,
0 commit comments