Skip to content

Commit 944e4b5

Browse files
feat: Interactive Workspace Standard - typed port, executor + environment seams (#226)
Implements #225: typed InteractiveSession port, CommandExecutor + Environment seams, exec-based credential seeding (docker-out-of-docker), plus review + live-e2e fixes (stdin creds, socket/heavy-dir skip in auth staging, honor-or-reject WorkspaceConfig, narrowed provider lock, workspace_dir metadata, readiness container-death detection). Closes #225.
1 parent 3b32928 commit 944e4b5

15 files changed

Lines changed: 3764 additions & 143 deletions

lib/python/agentic_isolation/agentic_isolation/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@
5151
WorkspaceConfig,
5252
)
5353
from agentic_isolation.providers import (
54+
AwaitResult,
5455
ExecuteResult,
56+
InteractiveSession,
5557
Workspace,
5658
WorkspaceDockerProvider,
5759
WorkspaceLocalProvider,
@@ -93,6 +95,8 @@
9395
"WorkspaceProvider",
9496
"Workspace",
9597
"ExecuteResult",
98+
"InteractiveSession",
99+
"AwaitResult",
96100
"WorkspaceLocalProvider",
97101
"WorkspaceDockerProvider",
98102
# Workspace file staging primitives

lib/python/agentic_isolation/agentic_isolation/providers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from typing import TYPE_CHECKING, Any
1313

1414
from agentic_isolation.providers.base import (
15+
AwaitResult,
1516
ExecuteResult,
17+
InteractiveSession,
1618
Workspace,
1719
WorkspaceProvider,
1820
)
@@ -46,6 +48,8 @@ def __getattr__(name: str) -> Any:
4648
"WorkspaceProvider",
4749
"Workspace",
4850
"ExecuteResult",
51+
"InteractiveSession",
52+
"AwaitResult",
4953
# Providers
5054
"WorkspaceLocalProvider",
5155
"WorkspaceDockerProvider",

lib/python/agentic_isolation/agentic_isolation/providers/base.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,59 @@ def to_dict(self) -> dict[str, Any]:
4444
}
4545

4646

47+
@runtime_checkable
48+
class AwaitResult(Protocol):
49+
"""Structural read port for the result of waiting for an interactive
50+
agent pane to reach a ready/idle state.
51+
52+
This is a *structural* Protocol, not a concrete dataclass. The
53+
interactive-tmux driver defines its own `AwaitResult` dataclass
54+
(`providers/workspaces/interactive-tmux/driver/interactive_tmux.py`)
55+
and that real object is what `interactive_session().await_completion()`
56+
actually returns. Declaring this as a Protocol (rather than a duplicate
57+
dataclass) means `isinstance(driver_result, AwaitResult)` is True at
58+
runtime, the two definitions can never drift, and `agentic_isolation`
59+
still never needs to import the driver (which is stdlib-only and lives
60+
outside this package).
61+
62+
Exposes only the read surface consumers need:
63+
64+
- ready=True -> adapter is_ready() held stable
65+
- timed_out=True, ready=False -> deadline hit before idle
66+
- ready=False, reason="never_ready" -> never reached even one ready frame
67+
- ready=False, reason="unstable" -> ready frames seen but pane kept changing
68+
69+
`pane` carries the last captured pane text so callers don't have to
70+
re-capture to inspect post-mortem; `success` mirrors `ready`.
71+
Serialization (`to_dict()`) lives on the concrete driver dataclass, not
72+
on this read port.
73+
"""
74+
75+
@property
76+
def ready(self) -> bool: ...
77+
78+
@property
79+
def timed_out(self) -> bool: ...
80+
81+
@property
82+
def reason(self) -> str: ...
83+
84+
@property
85+
def duration_ms(self) -> float: ...
86+
87+
@property
88+
def stable_polls_observed(self) -> int: ...
89+
90+
@property
91+
def pane(self) -> str: ...
92+
93+
@property
94+
def error(self) -> str | None: ...
95+
96+
@property
97+
def success(self) -> bool: ...
98+
99+
47100
@dataclass
48101
class Workspace:
49102
"""Represents an active isolated workspace."""
@@ -178,6 +231,48 @@ async def file_exists(
178231
...
179232

180233

234+
@runtime_checkable
235+
class InteractiveSession(Protocol):
236+
"""Typed port for driving an interactive, prompt-based agent session
237+
(e.g. a tmux-driven claude/codex/gemini TUI running in a container).
238+
239+
This is a structural protocol: any object exposing these three
240+
methods with compatible signatures satisfies it, including the
241+
driver's `InteractiveTmuxWorkspace` instance today (no wrapper class
242+
needed). It exists so consumers can type against a stable port
243+
instead of reaching into a provider-specific `Workspace._handle: Any`.
244+
"""
245+
246+
def send_message(self, agent: str, text: str) -> None:
247+
"""Send a message/prompt to the named agent's pane."""
248+
...
249+
250+
def await_completion(
251+
self,
252+
agent: str,
253+
*,
254+
timeout: float = 60.0,
255+
stable_polls: int = 4,
256+
poll_interval: float = 0.5,
257+
) -> AwaitResult:
258+
"""Block until the named agent's pane reaches a stable ready state.
259+
260+
Args:
261+
agent: Agent name (e.g. "claude", "codex", "gemini").
262+
timeout: Overall deadline in seconds.
263+
stable_polls: Number of consecutive "ready" polls required.
264+
poll_interval: Seconds between polls.
265+
266+
Returns:
267+
AwaitResult describing whether/how readiness was reached.
268+
"""
269+
...
270+
271+
def capture_response(self, agent: str) -> str:
272+
"""Capture the current response text from the named agent's pane."""
273+
...
274+
275+
181276
class BaseProvider(ABC):
182277
"""Abstract base class for workspace providers.
183278
@@ -241,6 +336,16 @@ async def file_exists(
241336
"""Check if file exists."""
242337
...
243338

339+
def interactive_session(self, workspace: Workspace) -> InteractiveSession | None:
340+
"""Return an `InteractiveSession` port for `workspace`, if supported.
341+
342+
Default implementation returns `None`: most providers (docker,
343+
local) don't support interactive prompt round-trips. Providers
344+
that do (e.g. `InteractiveTmuxProvider`) should override this to
345+
return an object satisfying the `InteractiveSession` protocol.
346+
"""
347+
return None
348+
244349
@staticmethod
245350
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
246351
"""Ensure a subprocess is terminated."""

lib/python/agentic_isolation/agentic_isolation/providers/interactive_tmux/__init__.py

Lines changed: 123 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
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()`
@@ -20,8 +20,10 @@
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

2729
from __future__ import annotations
@@ -42,6 +44,7 @@
4244
from 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

Comments
 (0)