levain-runtime 0.1.4__tar.gz → 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: levain-runtime
3
- Version: 0.1.4
3
+ Version: 0.1.9
4
4
  Summary: Self-hosted runtime for Levain agents: run an agent's nodes on your own machine or container while the platform drives the recipe
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -34,9 +34,12 @@ uv tool install levain-runtime
34
34
  ## Set up
35
35
 
36
36
  ```sh
37
- # Run nodes directly on this host (one active session at a time):
37
+ # Run nodes directly on this host:
38
38
  levain-runtime init --mode direct
39
39
 
40
+ # Direct mode serving four sessions at once (Linux with bubblewrap):
41
+ levain-runtime init --mode direct --sessions 4
42
+
40
43
  # Or run each session in a container built from your own image:
41
44
  levain-runtime init --mode docker --image ghcr.io/you/agent-runtime:latest
42
45
  ```
@@ -76,6 +79,9 @@ and `/sdk`, then runs each node as a short-lived process with the session's
76
79
  environment. Everything a node prints is streamed back and lands in the run's
77
80
  logs on the platform.
78
81
 
79
- Direct mode allows one active session at a time; `/workspace` points at the
80
- active session. Docker mode starts one container per session and has no such
81
- limit.
82
+ Direct mode serves one session at a time by default: `/workspace` points at
83
+ the active session. With `--sessions N` it serves up to N at once, each node
84
+ running under bubblewrap with its session's folders mounted at `/workspace`,
85
+ `/recipes/active` and `/sdk/active`; the real workspace path is in
86
+ `LEVAIN_HOST_WORKSPACE` for tools outside the sandbox such as Docker. Docker
87
+ mode starts one container per session and has no such limit.
@@ -19,9 +19,12 @@ uv tool install levain-runtime
19
19
  ## Set up
20
20
 
21
21
  ```sh
22
- # Run nodes directly on this host (one active session at a time):
22
+ # Run nodes directly on this host:
23
23
  levain-runtime init --mode direct
24
24
 
25
+ # Direct mode serving four sessions at once (Linux with bubblewrap):
26
+ levain-runtime init --mode direct --sessions 4
27
+
25
28
  # Or run each session in a container built from your own image:
26
29
  levain-runtime init --mode docker --image ghcr.io/you/agent-runtime:latest
27
30
  ```
@@ -61,6 +64,9 @@ and `/sdk`, then runs each node as a short-lived process with the session's
61
64
  environment. Everything a node prints is streamed back and lands in the run's
62
65
  logs on the platform.
63
66
 
64
- Direct mode allows one active session at a time; `/workspace` points at the
65
- active session. Docker mode starts one container per session and has no such
66
- limit.
67
+ Direct mode serves one session at a time by default: `/workspace` points at
68
+ the active session. With `--sessions N` it serves up to N at once, each node
69
+ running under bubblewrap with its session's folders mounted at `/workspace`,
70
+ `/recipes/active` and `/sdk/active`; the real workspace path is in
71
+ `LEVAIN_HOST_WORKSPACE` for tools outside the sandbox such as Docker. Docker
72
+ mode starts one container per session and has no such limit.
@@ -40,7 +40,7 @@ def _asset_name() -> str:
40
40
 
41
41
 
42
42
  def download(root: Path) -> Path:
43
- """Fetch the pinned release into ``<root>/bin`` after verifying its checksum."""
43
+ """Fetch the pinned release to :func:`binary_path` after verifying its checksum."""
44
44
  name = _asset_name()
45
45
  base = f"{_RELEASES}/v{CHISEL_VERSION}"
46
46
  with httpx.Client(follow_redirects=True, timeout=120) as client:
@@ -58,7 +58,7 @@ def download(root: Path) -> Path:
58
58
  actual = hashlib.sha256(blob.content).hexdigest()
59
59
  if actual != expected:
60
60
  raise RuntimeError(f"chisel checksum mismatch: {actual} != {expected}")
61
- target = root / "bin" / "chisel"
61
+ target = binary_path(root)
62
62
  target.parent.mkdir(parents=True, exist_ok=True)
63
63
  tmp = target.with_suffix(".tmp")
64
64
  tmp.write_bytes(gzip.decompress(blob.content))
@@ -67,11 +67,16 @@ def download(root: Path) -> Path:
67
67
  return target
68
68
 
69
69
 
70
+ def binary_path(root: Path) -> Path:
71
+ """Where the pinned release lives; named by version so a bump is fetched."""
72
+ return root / "bin" / f"chisel-{CHISEL_VERSION}"
73
+
74
+
70
75
  def resolve_binary(root: Path) -> Path:
71
76
  override = os.environ.get("CHISEL_BIN")
72
77
  if override:
73
78
  return Path(override)
74
- local = root / "bin" / "chisel"
79
+ local = binary_path(root)
75
80
  if local.is_file():
76
81
  return local
77
82
  log.info("downloading chisel %s", CHISEL_VERSION)
@@ -7,12 +7,13 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import shlex
10
+ import shutil
10
11
  import subprocess
11
12
  import sys
12
13
  import time
13
14
  from importlib import resources
14
15
  from pathlib import Path
15
- from typing import Annotated
16
+ from typing import Annotated, Any
16
17
 
17
18
  import httpx
18
19
  import typer
@@ -23,7 +24,7 @@ from .config import Credentials, Mode, RuntimeConfig, runtime_home
23
24
  from .heartbeat import package_version, run_heartbeat
24
25
  from .protocol import RUNNER_HTTP_PORT
25
26
  from .server import create_app
26
- from .sessions import SessionManager
27
+ from .sessions import BWRAP, SessionManager
27
28
 
28
29
  app = typer.Typer(
29
30
  name="levain-runtime",
@@ -35,6 +36,9 @@ _HOME_DIRS = ("recipes", "sdk", "sandbox", "sessions", "agents", "bin")
35
36
  _SYSTEM_LINKS = ("recipes", "sdk", "sandbox", "workspace")
36
37
  _SYNTHETIC_CONF = Path("/etc/synthetic.conf")
37
38
  _VENDORED = ("node_runner.py", "run_logging.py")
39
+ # Paths a sandboxed direct runtime binds per session, relative to the
40
+ # home; the root-level links point into it.
41
+ _SESSION_VIEWS = ("workspace", "recipes/active", "sdk/active")
38
42
 
39
43
 
40
44
  def _version_callback(value: bool) -> None:
@@ -72,10 +76,29 @@ def write_sandbox_files(root: Path) -> None:
72
76
  (root / "sandbox" / name).write_bytes(data)
73
77
 
74
78
 
75
- def prepare_home(root: Path) -> None:
79
+ def prepare_home(root: Path, *, sessions: int = 1) -> None:
76
80
  for name in _HOME_DIRS:
77
81
  (root / name).mkdir(parents=True, exist_ok=True)
78
82
  write_sandbox_files(root)
83
+ if sessions > 1:
84
+ # bubblewrap mounts over real directories only, never a link.
85
+ for name in _SESSION_VIEWS:
86
+ path = root / name
87
+ if path.is_symlink():
88
+ path.unlink()
89
+ path.mkdir(parents=True, exist_ok=True)
90
+ return
91
+ for name in _SESSION_VIEWS:
92
+ path = root / name
93
+ if path.is_dir() and not path.is_symlink():
94
+ # Left by a sandboxed setup; flip_link() can't replace a
95
+ # real directory, so clear it back to empty here.
96
+ try:
97
+ path.rmdir()
98
+ except OSError:
99
+ raise typer.BadParameter(
100
+ f"{path} is a directory with content; move it aside first"
101
+ ) from None
79
102
  idle = root / "sessions" / ".idle" / "workspace"
80
103
  idle.mkdir(parents=True, exist_ok=True)
81
104
  link = root / "workspace"
@@ -83,6 +106,34 @@ def prepare_home(root: Path) -> None:
83
106
  link.symlink_to(idle)
84
107
 
85
108
 
109
+ def check_sandbox(sessions: int) -> None:
110
+ """Fail early when ``sessions`` above one cannot be served here."""
111
+ if sessions < 1:
112
+ raise typer.BadParameter("--sessions must be at least 1")
113
+ if sessions == 1:
114
+ return
115
+ if sys.platform != "linux":
116
+ raise typer.BadParameter(
117
+ "several sessions at once need Linux; macOS serves one"
118
+ )
119
+ if shutil.which(BWRAP) is None:
120
+ raise typer.BadParameter(
121
+ "several sessions at once need bubblewrap; "
122
+ "install it (apt install bubblewrap) and retry"
123
+ )
124
+ probe = subprocess.run(
125
+ [BWRAP, "--dev-bind", "/", "/", "--die-with-parent", "--", "true"],
126
+ capture_output=True,
127
+ text=True,
128
+ check=False,
129
+ )
130
+ if probe.returncode != 0:
131
+ raise typer.BadParameter(
132
+ "bubblewrap cannot start a sandbox here: "
133
+ + (probe.stderr.strip() or f"exit {probe.returncode}")
134
+ )
135
+
136
+
86
137
  def system_link_commands(root: Path) -> list[list[str]]:
87
138
  """``ln`` commands still needed for ``/recipes`` & co.; empty when done."""
88
139
  cmds: list[list[str]] = []
@@ -172,12 +223,23 @@ def init(
172
223
  help="Copy this host's Claude login into docker sessions",
173
224
  ),
174
225
  ] = True,
226
+ sessions: Annotated[
227
+ int,
228
+ typer.Option(
229
+ help="Sessions served at once in direct mode "
230
+ "(above 1 needs Linux and bubblewrap)"
231
+ ),
232
+ ] = 1,
175
233
  ) -> None:
176
234
  """Prepare the runtime home and, in direct mode, the system links."""
177
235
  if mode == "docker" and not image:
178
236
  raise typer.BadParameter("--image is required in docker mode")
237
+ if mode == "direct":
238
+ check_sandbox(sessions)
239
+ else:
240
+ sessions = 1
179
241
  home = runtime_home()
180
- prepare_home(home)
242
+ prepare_home(home, sessions=sessions)
181
243
  existing = (
182
244
  RuntimeConfig.load(home) if (home / "config.json").is_file() else None
183
245
  )
@@ -186,12 +248,16 @@ def init(
186
248
  mode=mode,
187
249
  image=image,
188
250
  share_claude_login=share_claude_login,
251
+ sessions=sessions,
189
252
  credentials=existing.credentials if existing else None,
190
253
  )
191
254
  config.save()
192
255
  if mode == "direct":
193
256
  ensure_system_links(home)
194
- print(f"runtime ready at {home} ({mode})")
257
+ print(
258
+ f"runtime ready at {home} ({mode}"
259
+ + (f", {sessions} sessions at once)" if sessions > 1 else ")")
260
+ )
195
261
 
196
262
 
197
263
  @app.command()
@@ -371,32 +437,57 @@ def up() -> None:
371
437
  _serve(config)
372
438
 
373
439
 
440
+ def _live_health() -> dict[str, Any] | None:
441
+ try:
442
+ resp = httpx.get(
443
+ f"http://127.0.0.1:{RUNNER_HTTP_PORT}/v1/health", timeout=2.0
444
+ )
445
+ resp.raise_for_status()
446
+ body = resp.json()
447
+ except (httpx.HTTPError, ValueError):
448
+ return None
449
+ return body if isinstance(body, dict) else None
450
+
451
+
374
452
  @app.command()
375
453
  def status() -> None:
376
454
  """Show the configuration and the sessions on this runtime."""
377
455
  config = _load_config()
378
- manager = SessionManager(config)
456
+ # Read-only: the startup reset belongs to the running service.
457
+ manager = SessionManager(config, restore=False)
458
+ live = _live_health()
379
459
  print(f"home: {config.root}")
380
460
  print(
381
461
  f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
382
462
  )
463
+ print("service: " + ("running" if live else "not running"))
383
464
  if config.credentials:
384
465
  print(f"runtime: {config.credentials.runtime_id}")
385
- print(f"gateway: {chisel.gateway(config.credentials)}")
466
+ if live and live.get("gateway"):
467
+ print(f"gateway: {live['gateway']}")
468
+ else:
469
+ print(
470
+ f"gateway: {chisel.gateway(config.credentials)}"
471
+ " (from this shell)"
472
+ )
386
473
  else:
387
474
  print("runtime: not connected")
388
475
  if config.mode == "direct":
476
+ print(f"sessions at once: {config.sessions}")
389
477
  pending = system_link_commands(config.root)
390
478
  print("system links: " + ("ok" if not pending else "missing"))
391
479
  print(
392
480
  f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
393
481
  )
394
482
  sessions = manager.store.all()
395
- print(
396
- f"sessions: {len(sessions)} ({len(manager.active_sessions())} active)"
397
- )
483
+ if live:
484
+ active = set(live.get("active_sessions") or [])
485
+ else:
486
+ active = {m.sid for m in sessions if m.state == "active"}
487
+ print(f"sessions: {len(sessions)} ({len(active)} active)")
398
488
  for meta in sessions:
399
- print(f" {meta.sid} {meta.state} {meta.workspace_dir}")
489
+ state = "active" if meta.sid in active else "paused"
490
+ print(f" {meta.sid} {state} {meta.workspace_dir}")
400
491
 
401
492
 
402
493
  def _serve(config: RuntimeConfig) -> None:
@@ -82,6 +82,10 @@ class RuntimeConfig:
82
82
  mode: Mode = "direct"
83
83
  image: str | None = None
84
84
  share_claude_login: bool = True
85
+ # Sessions a direct runtime serves at once; above one, each node
86
+ # runs under bubblewrap with its session's folders at /workspace,
87
+ # /recipes/active and /sdk/active.
88
+ sessions: int = 1
85
89
  credentials: Credentials | None = None
86
90
 
87
91
  @property
@@ -97,6 +101,7 @@ class RuntimeConfig:
97
101
  mode=raw.get("mode", "direct"),
98
102
  image=raw.get("image"),
99
103
  share_claude_login=bool(raw.get("share_claude_login", True)),
104
+ sessions=max(1, int(raw.get("sessions") or 1)),
100
105
  credentials=Credentials.from_dict(creds) if creds else None,
101
106
  )
102
107
 
@@ -106,6 +111,7 @@ class RuntimeConfig:
106
111
  "mode": self.mode,
107
112
  "image": self.image,
108
113
  "share_claude_login": self.share_claude_login,
114
+ "sessions": self.sessions,
109
115
  "credentials": self.credentials.to_dict()
110
116
  if self.credentials
111
117
  else None,
@@ -31,6 +31,7 @@ async def run_heartbeat(
31
31
  payload = {
32
32
  "version": package_version(),
33
33
  "mode": manager.mode,
34
+ "capacity": manager.capacity,
34
35
  "active_sessions": len(manager.active_sessions()),
35
36
  }
36
37
  try:
@@ -0,0 +1,177 @@
1
+ """Execs that outlive the HTTP stream that started them (#2760).
2
+
3
+ A job drains ``stream_process`` into a numbered record list, so a
4
+ reader whose tunnel dropped can attach again at the record it last
5
+ saw. The process is killed on cancel, on its own timeout, or once no
6
+ reader has been attached for ``ORPHAN_SECONDS``: a node never outlives
7
+ the platform's interest in it, and a finished job nobody collects is
8
+ forgotten the same way.
9
+
10
+ A reattach whose last-seen record has already been trimmed from the
11
+ buffer is a gap the server can no longer fill; ``Job.has_gap`` lets
12
+ the caller refuse it rather than silently resuming past missing
13
+ records.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ import time
21
+ import uuid
22
+ from collections.abc import AsyncIterator, Callable
23
+ from typing import Any
24
+
25
+ from .protocol import JOB_ORPHAN_SECONDS
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ ORPHAN_SECONDS: float = JOB_ORPHAN_SECONDS
30
+
31
+
32
+ class Job:
33
+ def __init__(
34
+ self,
35
+ job_id: str,
36
+ sid: str,
37
+ stream: AsyncIterator[dict[str, Any]],
38
+ *,
39
+ orphan_seconds: float,
40
+ ) -> None:
41
+ self.id = job_id
42
+ self.sid = sid
43
+ self.records: list[dict[str, Any]] = []
44
+ self.done = False
45
+ self.readers = 0
46
+ self._trimmed = 0 # highest seq ever dropped from the front
47
+ self._generation = 0 # bumped on each attach; only the latest trims
48
+ self._idle_since = time.monotonic()
49
+ self._orphan = orphan_seconds
50
+ self._changed = asyncio.Condition()
51
+ self._drain = asyncio.create_task(self._run(stream))
52
+
53
+ def _next_seq(self) -> int:
54
+ return self._trimmed + len(self.records) + 1
55
+
56
+ async def _run(self, stream: AsyncIterator[dict[str, Any]]) -> None:
57
+ try:
58
+ async for item in stream:
59
+ async with self._changed:
60
+ self.records.append({"seq": self._next_seq(), **item})
61
+ self._changed.notify_all()
62
+ except asyncio.CancelledError:
63
+ raise
64
+ except Exception as exc:
65
+ logger.exception("job %s stream failed", self.id)
66
+ async with self._changed:
67
+ self.records.append(
68
+ {
69
+ "seq": self._next_seq(),
70
+ "exit": -1,
71
+ "error": str(exc),
72
+ }
73
+ )
74
+ self._changed.notify_all()
75
+ finally:
76
+ async with self._changed:
77
+ self.done = True
78
+ self._changed.notify_all()
79
+
80
+ def _trim(self, through: int) -> None:
81
+ """Drop buffered records with ``seq <= through``."""
82
+ drop = min(max(through, 0) - self._trimmed, len(self.records))
83
+ if drop > 0:
84
+ del self.records[:drop]
85
+ self._trimmed += drop
86
+
87
+ def has_gap(self, after: int) -> bool:
88
+ """True if a resuming reader's ``after`` can't be served.
89
+
90
+ Records are trimmed as soon as they're yielded to a reader,
91
+ not once that reader's tunnel has actually delivered them, so
92
+ a reattach can ask for a ``after`` that's already gone from
93
+ the buffer: the records between would be silently skipped.
94
+ A fresh attach always starts while ``_trimmed`` is still 0,
95
+ so ``after < self._trimmed`` already excludes it without
96
+ special-casing ``after == 0``.
97
+ """
98
+ return after < self._trimmed
99
+
100
+ async def attach(self, after: int) -> AsyncIterator[dict[str, Any]]:
101
+ """Records with ``seq > after``, live until the exit record.
102
+
103
+ Acknowledged records are dropped from the buffer as they're
104
+ yielded, so a chatty, long-running job doesn't grow memory
105
+ without bound even over a single unbroken stream; ``seq`` stays
106
+ monotonic across the trim. Only the most recently attached
107
+ reader trims: a reattach (the reader's tunnel dropped) bumps
108
+ the generation, so a prior reader whose disconnect hasn't been
109
+ noticed yet stops deleting records the new one still needs.
110
+ """
111
+ self.readers += 1
112
+ self._generation += 1
113
+ generation = self._generation
114
+ try:
115
+ async with self._changed:
116
+ self._trim(after)
117
+ i = 0
118
+ while True:
119
+ while i < len(self.records):
120
+ yield self.records[i]
121
+ i += 1
122
+ async with self._changed:
123
+ if i > 0 and self._generation == generation:
124
+ self._trim(self._trimmed + i)
125
+ i = 0
126
+ if self.done and i >= len(self.records):
127
+ return
128
+ await self._changed.wait_for(self._grew_past(i))
129
+ finally:
130
+ self.readers -= 1
131
+ self._idle_since = time.monotonic()
132
+
133
+ def _grew_past(self, n: int) -> Callable[[], bool]:
134
+ return lambda: self.done or len(self.records) > n
135
+
136
+ def orphaned(self) -> bool:
137
+ return (
138
+ self.readers == 0
139
+ and time.monotonic() - self._idle_since >= self._orphan
140
+ )
141
+
142
+ async def cancel(self) -> None:
143
+ # Cancelling the drain closes the generator, whose ``finally``
144
+ # kills the process group.
145
+ self._drain.cancel()
146
+ await asyncio.wait({self._drain})
147
+
148
+
149
+ class JobRegistry:
150
+ def __init__(self) -> None:
151
+ self._jobs: dict[str, Job] = {}
152
+ self._reaper: asyncio.Task[None] | None = None
153
+
154
+ def start(self, sid: str, stream: AsyncIterator[dict[str, Any]]) -> Job:
155
+ job = Job(uuid.uuid4().hex, sid, stream, orphan_seconds=ORPHAN_SECONDS)
156
+ self._jobs[job.id] = job
157
+ if self._reaper is None or self._reaper.done():
158
+ self._reaper = asyncio.create_task(self._reap())
159
+ return job
160
+
161
+ def get(self, sid: str, job_id: str) -> Job | None:
162
+ job = self._jobs.get(job_id)
163
+ return job if job is not None and job.sid == sid else None
164
+
165
+ async def cancel(self, job: Job) -> None:
166
+ self._jobs.pop(job.id, None)
167
+ await job.cancel()
168
+
169
+ async def cancel_for(self, sid: str) -> None:
170
+ for job in [j for j in self._jobs.values() if j.sid == sid]:
171
+ await self.cancel(job)
172
+
173
+ async def _reap(self) -> None:
174
+ while self._jobs:
175
+ await asyncio.sleep(min(ORPHAN_SECONDS / 4, 5.0))
176
+ for job in [j for j in self._jobs.values() if j.orphaned()]:
177
+ await self.cancel(job)
@@ -6,4 +6,8 @@ RUNNER_HTTP_PORT = 17777
6
6
  FORWARD_SANDBOX_API_PORT = 18010
7
7
  FORWARD_PROXY_PORT = 14100
8
8
 
9
- CHISEL_VERSION = "1.11.3"
9
+ CHISEL_VERSION = "1.12.0"
10
+
11
+ # A job whose reader has been gone this long is killed; the platform
12
+ # gives up reattaching after the same window (``remote.py``).
13
+ JOB_ORPHAN_SECONDS = 300
@@ -13,11 +13,13 @@ from starlette.requests import Request
13
13
  from starlette.responses import JSONResponse, Response, StreamingResponse
14
14
  from starlette.routing import Mount, Route
15
15
 
16
+ from . import chisel
16
17
  from .config import RuntimeConfig
17
18
  from .sessions import (
18
19
  MissingTarget,
19
20
  RuntimeBusy,
20
21
  SessionManager,
22
+ UnknownJob,
21
23
  UnknownSession,
22
24
  )
23
25
 
@@ -58,8 +60,19 @@ def _env_of(body: dict[str, Any]) -> dict[str, str]:
58
60
 
59
61
  async def health(request: Request) -> Response:
60
62
  m = _manager(request)
63
+ creds = m.config.credentials
61
64
  return JSONResponse(
62
- {"ok": True, "mode": m.mode, "active_sessions": m.active_sessions()}
65
+ {
66
+ "ok": True,
67
+ "mode": m.mode,
68
+ "capacity": m.capacity,
69
+ "active_sessions": m.active_sessions(),
70
+ # Execs can run as jobs the platform reattaches to (#2760).
71
+ "jobs": True,
72
+ # Resolved in the service's own environment, which may
73
+ # override the configured gateway.
74
+ "gateway": chisel.gateway(creds) if creds else None,
75
+ }
63
76
  )
64
77
 
65
78
 
@@ -94,9 +107,7 @@ async def resume(request: Request) -> Response:
94
107
  return Response(status_code=204)
95
108
 
96
109
 
97
- async def exec_(request: Request) -> Response:
98
- m = _manager(request)
99
- sid = _sid(request)
110
+ async def _exec_body(request: Request) -> dict[str, Any]:
100
111
  body = await _json_body(request)
101
112
  cmd = body.get("cmd")
102
113
  if not isinstance(cmd, str) or not cmd:
@@ -104,9 +115,15 @@ async def exec_(request: Request) -> Response:
104
115
  stdin = body.get("stdin")
105
116
  if stdin is not None and not isinstance(stdin, str):
106
117
  raise ValueError("stdin must be a string")
107
- timeout = float(body.get("timeout") or 0)
108
- stream = m.exec(sid, cmd, _env_of(body), stdin=stdin, timeout=timeout)
118
+ return {
119
+ "cmd": cmd,
120
+ "env": _env_of(body),
121
+ "stdin": stdin,
122
+ "timeout": float(body.get("timeout") or 0),
123
+ }
124
+
109
125
 
126
+ def _ndjson(stream: AsyncIterator[dict[str, Any]]) -> StreamingResponse:
110
127
  async def lines() -> AsyncIterator[bytes]:
111
128
  async for item in stream:
112
129
  yield (json.dumps(item) + "\n").encode()
@@ -114,6 +131,36 @@ async def exec_(request: Request) -> Response:
114
131
  return StreamingResponse(lines(), media_type="application/x-ndjson")
115
132
 
116
133
 
134
+ async def exec_(request: Request) -> Response:
135
+ m = _manager(request)
136
+ sid = _sid(request)
137
+ args = await _exec_body(request)
138
+ return _ndjson(m.exec(sid, **args))
139
+
140
+
141
+ async def jobs(request: Request) -> Response:
142
+ m = _manager(request)
143
+ sid = _sid(request)
144
+ args = await _exec_body(request)
145
+ job = m.start_job(sid, **args)
146
+ return JSONResponse({"job_id": job.id}, status_code=201)
147
+
148
+
149
+ async def job(request: Request) -> Response:
150
+ m = _manager(request)
151
+ job = m.job(_sid(request), request.path_params["job_id"])
152
+ if request.method == "DELETE":
153
+ await m.jobs.cancel(job)
154
+ return Response(status_code=204)
155
+ try:
156
+ after = int(request.query_params.get("after", "0"))
157
+ except ValueError as exc:
158
+ raise ValueError("after must be an integer") from exc
159
+ if job.has_gap(after):
160
+ return Response(status_code=410)
161
+ return _ndjson(job.attach(after))
162
+
163
+
117
164
  async def capture(request: Request) -> Response:
118
165
  m = _manager(request)
119
166
  sid = _sid(request)
@@ -184,6 +231,13 @@ def _error(status: int) -> Any:
184
231
  return handler
185
232
 
186
233
 
234
+ async def _busy(request: Request, exc: Exception) -> Response:
235
+ assert isinstance(exc, RuntimeBusy)
236
+ return JSONResponse(
237
+ {"detail": str(exc), "session_id": exc.session_id}, status_code=409
238
+ )
239
+
240
+
187
241
  def create_app(config: RuntimeConfig) -> Starlette:
188
242
  app = Starlette(
189
243
  routes=[
@@ -199,6 +253,12 @@ def create_app(config: RuntimeConfig) -> Starlette:
199
253
  Route("/sessions/{sid}/pause", pause, methods=["POST"]),
200
254
  Route("/sessions/{sid}/resume", resume, methods=["POST"]),
201
255
  Route("/sessions/{sid}/exec", exec_, methods=["POST"]),
256
+ Route("/sessions/{sid}/jobs", jobs, methods=["POST"]),
257
+ Route(
258
+ "/sessions/{sid}/jobs/{job_id}",
259
+ job,
260
+ methods=["GET", "DELETE"],
261
+ ),
202
262
  Route(
203
263
  "/sessions/{sid}/capture", capture, methods=["POST"]
204
264
  ),
@@ -218,8 +278,9 @@ def create_app(config: RuntimeConfig) -> Starlette:
218
278
  exception_handlers={
219
279
  ValueError: _error(400),
220
280
  UnknownSession: _error(404),
281
+ UnknownJob: _error(404),
221
282
  MissingTarget: _error(404),
222
- RuntimeBusy: _error(409),
283
+ RuntimeBusy: _busy,
223
284
  },
224
285
  )
225
286
  app.state.manager = SessionManager(config)
@@ -10,11 +10,12 @@ import shutil
10
10
  import tarfile
11
11
  import uuid
12
12
  from collections.abc import AsyncIterator, Awaitable, Callable
13
- from dataclasses import asdict, dataclass
13
+ from dataclasses import asdict, dataclass, field
14
14
  from pathlib import Path
15
15
  from typing import IO, Any, Literal
16
16
 
17
17
  from .config import RuntimeConfig
18
+ from .jobs import Job, JobRegistry
18
19
  from .process import host_env, run_process, stream_process
19
20
 
20
21
  log = logging.getLogger("levain_runtime")
@@ -25,12 +26,20 @@ _SESSION_FILE = "session.json"
25
26
  # Trees under these roots are host bind mounts in docker mode, so they
26
27
  # are extracted on the host for both backends.
27
28
  _HOST_TREE_ROOTS = ("/recipes", "/sdk")
29
+ WORKSPACE_PATH = "/workspace"
30
+ BWRAP = "bwrap"
28
31
  _CLAUDE_READY_SENTINEL = "/tmp/.levain-runtime-ready"
29
32
 
30
33
  Runner = Callable[..., Awaitable[tuple[int, bytes, bytes]]]
31
34
 
32
35
 
33
36
  class RuntimeBusy(Exception):
37
+ def __init__(self, message: str, *, session_id: str) -> None:
38
+ super().__init__(message)
39
+ self.session_id = session_id
40
+
41
+
42
+ class UnknownJob(Exception):
34
43
  pass
35
44
 
36
45
 
@@ -52,6 +61,9 @@ class SessionMeta:
52
61
  env: dict[str, str]
53
62
  workspace_dir: str
54
63
  state: State = "paused"
64
+ # Per-session views (``/recipes/active`` -> ``/recipes/<fp>``) on a
65
+ # sandboxed direct runtime; the host symlink is flipped elsewhere.
66
+ trees: dict[str, str] = field(default_factory=dict)
55
67
 
56
68
  def to_dict(self) -> dict[str, Any]:
57
69
  return asdict(self)
@@ -194,11 +206,24 @@ class SessionStore:
194
206
 
195
207
 
196
208
  class DirectBackend:
197
- """Nodes run as host processes; one active session owns ``/workspace``."""
209
+ """Nodes run as host processes.
198
210
 
199
- def __init__(self, root: Path) -> None:
211
+ With a capacity of one, the active session owns the root-level
212
+ ``/workspace`` link. Above one, each node runs under bubblewrap,
213
+ which binds the session's own folders over ``/workspace``,
214
+ ``/recipes/active`` and ``/sdk/active`` in a private mount
215
+ namespace; the rest of the host looks unchanged.
216
+ """
217
+
218
+ def __init__(self, root: Path, *, capacity: int = 1) -> None:
200
219
  self.root = root
201
- self.active: str | None = None
220
+ self.capacity = max(1, capacity)
221
+ # Holders, oldest first.
222
+ self.active: list[str] = []
223
+
224
+ @property
225
+ def sandboxed(self) -> bool:
226
+ return self.capacity > 1
202
227
 
203
228
  @property
204
229
  def idle_workspace(self) -> Path:
@@ -212,24 +237,81 @@ class DirectBackend:
212
237
  os.replace(tmp, link)
213
238
 
214
239
  async def activate(self, meta: SessionMeta) -> None:
215
- if self.active not in (None, meta.sid):
216
- raise RuntimeBusy("runtime busy")
217
- self._point_workspace(Path(meta.workspace_dir))
218
- self.active = meta.sid
240
+ if meta.sid not in self.active:
241
+ if len(self.active) >= self.capacity:
242
+ raise RuntimeBusy(
243
+ f"runtime full ({self.capacity} sessions)"
244
+ if self.sandboxed
245
+ else "runtime busy",
246
+ session_id=self.active[0],
247
+ )
248
+ self.active.append(meta.sid)
249
+ if not self.sandboxed:
250
+ self._point_workspace(Path(meta.workspace_dir))
219
251
 
220
252
  async def release(self, meta: SessionMeta) -> None:
221
- if self.active == meta.sid:
222
- self.active = None
253
+ if meta.sid not in self.active:
254
+ return
255
+ self.active.remove(meta.sid)
256
+ if not self.sandboxed:
223
257
  self._point_workspace(self.idle_workspace)
224
258
 
225
259
  async def destroy(self, meta: SessionMeta) -> None:
226
260
  await self.release(meta)
227
261
 
228
262
  def status(self, meta: SessionMeta) -> State:
229
- return "active" if self.active == meta.sid else "paused"
263
+ return "active" if meta.sid in self.active else "paused"
264
+
265
+ def views(self, meta: SessionMeta) -> dict[str, str]:
266
+ """Node-visible path -> the session's folder behind it."""
267
+ return {WORKSPACE_PATH: meta.workspace_dir, **meta.trees}
268
+
269
+ def session_path(self, meta: SessionMeta, path: str) -> Path:
270
+ """Where ``path`` lives on the host for this session.
271
+
272
+ Only differs from ``path`` in sandboxed mode, where the views
273
+ exist inside each node's mount namespace.
274
+ """
275
+ if self.sandboxed:
276
+ for view, real in self.views(meta).items():
277
+ if path == view or path.startswith(view + "/"):
278
+ return Path(real + path[len(view) :])
279
+ return Path(path)
280
+
281
+ def _tmp_dir(self, meta: SessionMeta) -> Path:
282
+ return self.root / "sessions" / meta.sid / "tmp"
230
283
 
231
284
  def _env(self, meta: SessionMeta, extra: dict[str, str]) -> dict[str, str]:
232
- return {**host_env(), **meta.env, **extra}
285
+ env = {**host_env(), **meta.env, **extra}
286
+ if self.sandboxed:
287
+ # Daemons outside the sandbox (docker, a devcontainer CLI)
288
+ # resolve paths on the host, so recipes hand them this.
289
+ env["LEVAIN_HOST_WORKSPACE"] = meta.workspace_dir
290
+ # /tmp stays shared (host tools keep sockets there); the
291
+ # session's own scratch keeps file names from clashing.
292
+ tmp = self._tmp_dir(meta)
293
+ tmp.mkdir(parents=True, exist_ok=True)
294
+ env["TMPDIR"] = str(tmp)
295
+ return env
296
+
297
+ def argv(self, meta: SessionMeta, cmd: str) -> list[str]:
298
+ shell = ["/bin/sh", "-c", cmd]
299
+ if not self.sandboxed:
300
+ return shell
301
+ argv = [BWRAP, "--dev-bind", "/", "/"]
302
+ for view, real in self.views(meta).items():
303
+ # bubblewrap mounts on the resolved host path, never on a
304
+ # symlink; ``init`` made these real directories.
305
+ argv += ["--bind", real, os.path.realpath(view)]
306
+ argv += ["--chdir", WORKSPACE_PATH, "--die-with-parent", "--", *shell]
307
+ return argv
308
+
309
+ def _cwd(self, meta: SessionMeta) -> str | None:
310
+ if self.sandboxed:
311
+ return None
312
+ return (
313
+ meta.workspace_dir if Path(meta.workspace_dir).is_dir() else None
314
+ )
233
315
 
234
316
  def exec(
235
317
  self,
@@ -241,13 +323,11 @@ class DirectBackend:
241
323
  timeout: float,
242
324
  ) -> AsyncIterator[dict[str, Any]]:
243
325
  return stream_process(
244
- ["/bin/sh", "-c", cmd],
326
+ self.argv(meta, cmd),
245
327
  env=self._env(meta, env),
246
328
  stdin=stdin.encode() if stdin is not None else None,
247
329
  timeout=timeout,
248
- cwd=meta.workspace_dir
249
- if Path(meta.workspace_dir).is_dir()
250
- else None,
330
+ cwd=self._cwd(meta),
251
331
  )
252
332
 
253
333
  async def capture(
@@ -259,33 +339,37 @@ class DirectBackend:
259
339
  timeout: float,
260
340
  ) -> tuple[str, int]:
261
341
  rc, out, _ = await run_process(
262
- ["/bin/sh", "-c", cmd],
342
+ self.argv(meta, cmd),
263
343
  env=self._env(meta, env),
264
344
  timeout=timeout,
265
- cwd=meta.workspace_dir
266
- if Path(meta.workspace_dir).is_dir()
267
- else None,
345
+ cwd=self._cwd(meta),
268
346
  )
269
347
  return out.decode("utf-8", "replace"), rc
270
348
 
271
349
  async def put_file(
272
350
  self, meta: SessionMeta, path: str, data: bytes
273
351
  ) -> None:
274
- p = Path(path)
352
+ p = self.session_path(meta, path)
275
353
  p.parent.mkdir(parents=True, exist_ok=True)
276
354
  p.write_bytes(data)
277
355
 
278
356
  async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
279
- p = Path(path)
357
+ p = self.session_path(meta, path)
280
358
  return p.read_bytes() if p.is_file() else None
281
359
 
282
360
  async def put_tree(
283
361
  self, meta: SessionMeta, path: str, src: IO[bytes]
284
362
  ) -> None:
285
- extract_tar(src, Path(path))
363
+ extract_tar(src, self.session_path(meta, path))
286
364
 
287
365
  async def link(self, meta: SessionMeta, path: str, target: str) -> None:
288
- flip_link(Path(path), target, exists_at=Path(target))
366
+ if not self.sandboxed:
367
+ flip_link(Path(path), target, exists_at=Path(target))
368
+ return
369
+ # The view is per session: recorded here, bound at each exec.
370
+ if not Path(target).is_dir():
371
+ raise MissingTarget(f"{target} is not on this runtime")
372
+ meta.trees[path] = target
289
373
 
290
374
 
291
375
  class DockerBackend:
@@ -552,9 +636,10 @@ class DockerBackend:
552
636
  class SessionManager:
553
637
  """Routes session operations to the configured backend."""
554
638
 
555
- def __init__(self, config: RuntimeConfig) -> None:
639
+ def __init__(self, config: RuntimeConfig, *, restore: bool = True) -> None:
556
640
  self.config = config
557
641
  self.store = SessionStore(config.root)
642
+ self.jobs = JobRegistry()
558
643
  self.backend: DirectBackend | DockerBackend
559
644
  if config.mode == "docker":
560
645
  if not config.image:
@@ -565,14 +650,15 @@ class SessionManager:
565
650
  share_claude_login=config.share_claude_login,
566
651
  )
567
652
  else:
568
- self.backend = DirectBackend(config.root)
569
- self._restore_direct_slot()
653
+ self.backend = DirectBackend(config.root, capacity=config.sessions)
654
+ if restore:
655
+ self._restore_direct_slots()
570
656
 
571
- def _restore_direct_slot(self) -> None:
657
+ def _restore_direct_slots(self) -> None:
572
658
  assert isinstance(self.backend, DirectBackend)
573
659
  active = [m for m in self.store.all() if m.state == "active"]
574
- # Only one session may own /workspace; after a restart, any
575
- # recorded holder is stale until the platform resumes it.
660
+ # Slots live in memory; after a restart, any recorded holder is
661
+ # stale until the platform resumes it.
576
662
  for meta in active:
577
663
  meta.state = "paused"
578
664
  self.store.save(meta)
@@ -587,7 +673,16 @@ class SessionManager:
587
673
  raise UnknownSession(sid)
588
674
  return meta
589
675
 
676
+ @property
677
+ def capacity(self) -> int | None:
678
+ """Sessions served at once; ``None`` when nothing bounds it."""
679
+ if isinstance(self.backend, DirectBackend):
680
+ return self.backend.capacity
681
+ return None
682
+
590
683
  def active_sessions(self) -> list[str]:
684
+ if isinstance(self.backend, DirectBackend):
685
+ return list(self.backend.active)
591
686
  return [m.sid for m in self.store.all() if m.state == "active"]
592
687
 
593
688
  async def provision(
@@ -648,6 +743,7 @@ class SessionManager:
648
743
  meta = self.store.get(sid)
649
744
  if meta is None:
650
745
  return
746
+ await self.jobs.cancel_for(sid)
651
747
  await self.backend.destroy(meta)
652
748
  self.store.delete(sid, keep_workspace=keep_workspace)
653
749
 
@@ -663,6 +759,26 @@ class SessionManager:
663
759
  meta = self.require(sid)
664
760
  return self.backend.exec(meta, cmd, env, stdin=stdin, timeout=timeout)
665
761
 
762
+ def start_job(
763
+ self,
764
+ sid: str,
765
+ cmd: str,
766
+ env: dict[str, str],
767
+ *,
768
+ stdin: str | None,
769
+ timeout: float,
770
+ ) -> Job:
771
+ return self.jobs.start(
772
+ sid, self.exec(sid, cmd, env, stdin=stdin, timeout=timeout)
773
+ )
774
+
775
+ def job(self, sid: str, job_id: str) -> Job:
776
+ self.require(sid)
777
+ job = self.jobs.get(sid, job_id)
778
+ if job is None:
779
+ raise UnknownJob(job_id)
780
+ return job
781
+
666
782
  async def capture(
667
783
  self, sid: str, cmd: str, env: dict[str, str], *, timeout: float
668
784
  ) -> tuple[str, int]:
@@ -685,11 +801,11 @@ class SessionManager:
685
801
  )
686
802
 
687
803
  async def link(self, sid: str, path: str, target: str) -> None:
804
+ meta = self.require(sid)
688
805
  await self.backend.link(
689
- self.require(sid),
690
- validate_abs_path(path),
691
- validate_abs_path(target),
806
+ meta, validate_abs_path(path), validate_abs_path(target)
692
807
  )
808
+ self.store.save(meta)
693
809
 
694
810
  async def reset_agent_workspace(
695
811
  self, agent_id: str, *, user: str | None
@@ -702,20 +818,28 @@ class SessionManager:
702
818
  scope="user" if user else "agent",
703
819
  user=user,
704
820
  )
821
+ live = set(self.active_sessions())
705
822
  for meta in self.store.all():
706
- if meta.workspace_dir == str(target) and meta.state == "active":
707
- raise RuntimeBusy(f"workspace in use by session {meta.sid}")
823
+ if meta.workspace_dir == str(target) and (
824
+ meta.state == "active" or meta.sid in live
825
+ ):
826
+ raise RuntimeBusy(
827
+ f"workspace in use by session {meta.sid}",
828
+ session_id=meta.sid,
829
+ )
708
830
  shutil.rmtree(target, ignore_errors=True)
709
831
  target.mkdir(parents=True, exist_ok=True)
710
832
 
711
833
 
712
834
  __all__ = [
835
+ "BWRAP",
713
836
  "DirectBackend",
714
837
  "DockerBackend",
715
838
  "MissingTarget",
716
839
  "RuntimeBusy",
717
840
  "SessionManager",
718
841
  "SessionMeta",
842
+ "UnknownJob",
719
843
  "UnknownSession",
720
844
  "validate_abs_path",
721
845
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "levain-runtime"
3
- version = "0.1.4"
3
+ version = "0.1.9"
4
4
  description = "Self-hosted runtime for Levain agents: run an agent's nodes on your own machine or container while the platform drives the recipe"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
File without changes