levain-runtime 0.1.2__tar.gz → 0.1.10__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.
Files changed (19) hide show
  1. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/.gitignore +7 -0
  2. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/PKG-INFO +11 -5
  3. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/README.md +10 -4
  4. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/chisel.py +18 -4
  5. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/cli.py +200 -11
  6. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/config.py +10 -0
  7. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/heartbeat.py +1 -0
  8. levain_runtime-0.1.10/levain_runtime/jobs.py +177 -0
  9. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/platform_api.py +29 -0
  10. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/protocol.py +5 -1
  11. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/server.py +68 -7
  12. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sessions.py +160 -35
  13. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/pyproject.toml +1 -1
  14. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/LICENSE +0 -0
  15. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/__init__.py +0 -0
  16. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/__main__.py +0 -0
  17. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/process.py +0 -0
  18. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/node_runner.py.vendored +0 -0
  19. {levain_runtime-0.1.2 → levain_runtime-0.1.10}/levain_runtime/sandbox/run_logging.py.vendored +0 -0
@@ -18,6 +18,10 @@ build/
18
18
  !**/platform_agents/build/
19
19
  !**/src/build/
20
20
 
21
+ # Maven build output
22
+ services/flink/target/
23
+ services/flink/dependency-reduced-pom.xml
24
+
21
25
  # Binaries
22
26
  bin/
23
27
 
@@ -136,3 +140,6 @@ assets/video/*/renders/
136
140
  # www/public/videos/, the composition that made them does not.
137
141
  assets/video/updates-*/
138
142
  assets/video/*/snapshots/
143
+
144
+ # Maven build output (Flink job); the image build compiles from source.
145
+ services/flink/target/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: levain-runtime
3
- Version: 0.1.2
3
+ Version: 0.1.10
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)
@@ -90,6 +95,15 @@ def server_url(gateway_url: str) -> str:
90
95
  return gateway_url
91
96
 
92
97
 
98
+ def gateway(creds: Credentials) -> str:
99
+ """The gateway to dial: the platform's, unless this host overrides it.
100
+
101
+ A runner on the platform cluster's own node dials the gateway Service
102
+ directly instead of looping out through the public edge and back.
103
+ """
104
+ return os.environ.get("LEVAIN_RUNTIME_GATEWAY_URL") or creds.gateway_url
105
+
106
+
93
107
  def client_argv(binary: Path, creds: Credentials) -> list[str]:
94
108
  remotes = [f"R:{creds.reverse_port}:127.0.0.1:{RUNNER_HTTP_PORT}"]
95
109
  remotes += [f"{f.local_port}:{f.remote}" for f in creds.forwards]
@@ -102,7 +116,7 @@ def client_argv(binary: Path, creds: Credentials) -> list[str]:
102
116
  # default lets the reconnect wait grow to five minutes.
103
117
  "--max-retry-interval",
104
118
  "30s",
105
- server_url(creds.gateway_url),
119
+ server_url(gateway(creds)),
106
120
  *remotes,
107
121
  ]
108
122
 
@@ -7,11 +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
13
+ import time
12
14
  from importlib import resources
13
15
  from pathlib import Path
14
- from typing import Annotated
16
+ from typing import Annotated, Any
15
17
 
16
18
  import httpx
17
19
  import typer
@@ -22,7 +24,7 @@ from .config import Credentials, Mode, RuntimeConfig, runtime_home
22
24
  from .heartbeat import package_version, run_heartbeat
23
25
  from .protocol import RUNNER_HTTP_PORT
24
26
  from .server import create_app
25
- from .sessions import SessionManager
27
+ from .sessions import BWRAP, SessionManager
26
28
 
27
29
  app = typer.Typer(
28
30
  name="levain-runtime",
@@ -34,6 +36,9 @@ _HOME_DIRS = ("recipes", "sdk", "sandbox", "sessions", "agents", "bin")
34
36
  _SYSTEM_LINKS = ("recipes", "sdk", "sandbox", "workspace")
35
37
  _SYNTHETIC_CONF = Path("/etc/synthetic.conf")
36
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")
37
42
 
38
43
 
39
44
  def _version_callback(value: bool) -> None:
@@ -71,10 +76,29 @@ def write_sandbox_files(root: Path) -> None:
71
76
  (root / "sandbox" / name).write_bytes(data)
72
77
 
73
78
 
74
- def prepare_home(root: Path) -> None:
79
+ def prepare_home(root: Path, *, sessions: int | None = 1) -> None:
75
80
  for name in _HOME_DIRS:
76
81
  (root / name).mkdir(parents=True, exist_ok=True)
77
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
78
102
  idle = root / "sessions" / ".idle" / "workspace"
79
103
  idle.mkdir(parents=True, exist_ok=True)
80
104
  link = root / "workspace"
@@ -82,6 +106,47 @@ def prepare_home(root: Path) -> None:
82
106
  link.symlink_to(idle)
83
107
 
84
108
 
109
+ def check_sandbox(sessions: int | None) -> int | None:
110
+ """The sessions a direct runtime here serves at once.
111
+
112
+ ``None`` asks for no cap, which needs Linux and bubblewrap: a
113
+ machine without them serves one, and the reason is printed. An
114
+ explicit count above one must be servable or the command fails.
115
+ """
116
+ if sessions is not None and sessions < 1:
117
+ raise typer.BadParameter("--sessions must be at least 1")
118
+ if sessions == 1:
119
+ return 1
120
+ problem = _sandbox_problem()
121
+ if problem is None:
122
+ return sessions
123
+ if sessions is None:
124
+ print(f"serving one session at a time: {problem}")
125
+ return 1
126
+ raise typer.BadParameter(problem)
127
+
128
+
129
+ def _sandbox_problem() -> str | None:
130
+ if sys.platform != "linux":
131
+ return "several sessions at once need Linux; macOS serves one"
132
+ if shutil.which(BWRAP) is None:
133
+ return (
134
+ "several sessions at once need bubblewrap; "
135
+ "install it (apt install bubblewrap) and retry"
136
+ )
137
+ probe = subprocess.run(
138
+ [BWRAP, "--dev-bind", "/", "/", "--die-with-parent", "--", "true"],
139
+ capture_output=True,
140
+ text=True,
141
+ check=False,
142
+ )
143
+ if probe.returncode != 0:
144
+ return "bubblewrap cannot start a sandbox here: " + (
145
+ probe.stderr.strip() or f"exit {probe.returncode}"
146
+ )
147
+ return None
148
+
149
+
85
150
  def system_link_commands(root: Path) -> list[list[str]]:
86
151
  """``ln`` commands still needed for ``/recipes`` & co.; empty when done."""
87
152
  cmds: list[list[str]] = []
@@ -171,12 +236,24 @@ def init(
171
236
  help="Copy this host's Claude login into docker sessions",
172
237
  ),
173
238
  ] = True,
239
+ sessions: Annotated[
240
+ int | None,
241
+ typer.Option(
242
+ help="Cap on sessions served at once in direct mode; by default "
243
+ "as many as the machine can isolate (above 1 needs Linux and "
244
+ "bubblewrap)"
245
+ ),
246
+ ] = None,
174
247
  ) -> None:
175
248
  """Prepare the runtime home and, in direct mode, the system links."""
176
249
  if mode == "docker" and not image:
177
250
  raise typer.BadParameter("--image is required in docker mode")
251
+ if mode == "direct":
252
+ sessions = check_sandbox(sessions)
253
+ else:
254
+ sessions = 1
178
255
  home = runtime_home()
179
- prepare_home(home)
256
+ prepare_home(home, sessions=sessions)
180
257
  existing = (
181
258
  RuntimeConfig.load(home) if (home / "config.json").is_file() else None
182
259
  )
@@ -185,12 +262,23 @@ def init(
185
262
  mode=mode,
186
263
  image=image,
187
264
  share_claude_login=share_claude_login,
265
+ sessions=sessions,
188
266
  credentials=existing.credentials if existing else None,
189
267
  )
190
268
  config.save()
191
269
  if mode == "direct":
192
270
  ensure_system_links(home)
193
- print(f"runtime ready at {home} ({mode})")
271
+ served = (
272
+ "no cap on sessions at once"
273
+ if sessions is None
274
+ else f"{sessions} sessions at once"
275
+ if sessions > 1
276
+ else ""
277
+ )
278
+ print(
279
+ f"runtime ready at {home} ({mode}"
280
+ + (f", {served})" if served else ")")
281
+ )
194
282
 
195
283
 
196
284
  @app.command()
@@ -283,6 +371,82 @@ def register(
283
371
  print("next: levain-runtime up")
284
372
 
285
373
 
374
+ def _detach_and_send(
375
+ root: Path,
376
+ creds: Credentials,
377
+ event_type: str,
378
+ payload: object,
379
+ agent: str | None,
380
+ ) -> None:
381
+ """Deliver an event from a detached child; the caller returns now.
382
+
383
+ A hook must never wait on the network, so the parent forks and
384
+ exits while the child drops the inherited stdio (a caller reading
385
+ our pipes would otherwise block on the child) and posts the event.
386
+ Failures land in ``emit.log`` under the runtime home.
387
+ """
388
+ if os.fork() > 0:
389
+ return
390
+ os.setsid()
391
+ devnull = os.open(os.devnull, os.O_RDWR)
392
+ for fd in (0, 1, 2):
393
+ os.dup2(devnull, fd)
394
+ try:
395
+ platform_api.post_event(creds, event_type, payload, agent)
396
+ except Exception as exc: # nothing above us to handle it
397
+ with (root / "emit.log").open("a") as fh:
398
+ fh.write(
399
+ f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {event_type}: {exc}\n"
400
+ )
401
+ finally:
402
+ os._exit(0)
403
+
404
+
405
+ @app.command()
406
+ def emit(
407
+ event_type: Annotated[
408
+ str,
409
+ typer.Argument(help="Event name, e.g. runtime.claude_code.stop"),
410
+ ],
411
+ agent: Annotated[
412
+ str | None,
413
+ typer.Option(help="Only dispatch this agent (slug)"),
414
+ ] = None,
415
+ payload: Annotated[
416
+ str | None,
417
+ typer.Option(help="JSON payload, or - to read it from stdin"),
418
+ ] = None,
419
+ wait: Annotated[
420
+ bool,
421
+ typer.Option(
422
+ "--wait", help="Send in the foreground and print the result"
423
+ ),
424
+ ] = False,
425
+ ) -> None:
426
+ """Raise an event for agents subscribed to it on this runtime.
427
+
428
+ Reads the payload, then returns immediately while a detached child
429
+ delivers it — safe to call from hooks. Use --wait to send in the
430
+ foreground and see the platform's response.
431
+ """
432
+ config = _load_config()
433
+ creds = config.credentials
434
+ if creds is None:
435
+ raise typer.BadParameter(
436
+ "no credentials; run `levain-runtime connect`"
437
+ )
438
+ raw = sys.stdin.read() if payload == "-" else payload
439
+ try:
440
+ parsed = json.loads(raw) if raw and raw.strip() else None
441
+ except json.JSONDecodeError as exc:
442
+ raise typer.BadParameter(f"payload is not valid JSON: {exc}") from None
443
+ if wait or not hasattr(os, "fork"):
444
+ result = platform_api.post_event(creds, event_type, parsed, agent)
445
+ print(json.dumps(result))
446
+ return
447
+ _detach_and_send(config.root, creds, event_type, parsed, agent)
448
+
449
+
286
450
  @app.command()
287
451
  def up() -> None:
288
452
  """Start the node server, the tunnel and the heartbeat."""
@@ -294,32 +458,57 @@ def up() -> None:
294
458
  _serve(config)
295
459
 
296
460
 
461
+ def _live_health() -> dict[str, Any] | None:
462
+ try:
463
+ resp = httpx.get(
464
+ f"http://127.0.0.1:{RUNNER_HTTP_PORT}/v1/health", timeout=2.0
465
+ )
466
+ resp.raise_for_status()
467
+ body = resp.json()
468
+ except (httpx.HTTPError, ValueError):
469
+ return None
470
+ return body if isinstance(body, dict) else None
471
+
472
+
297
473
  @app.command()
298
474
  def status() -> None:
299
475
  """Show the configuration and the sessions on this runtime."""
300
476
  config = _load_config()
301
- manager = SessionManager(config)
477
+ # Read-only: the startup reset belongs to the running service.
478
+ manager = SessionManager(config, restore=False)
479
+ live = _live_health()
302
480
  print(f"home: {config.root}")
303
481
  print(
304
482
  f"mode: {config.mode}" + (f" ({config.image})" if config.image else "")
305
483
  )
484
+ print("service: " + ("running" if live else "not running"))
306
485
  if config.credentials:
307
486
  print(f"runtime: {config.credentials.runtime_id}")
308
- print(f"gateway: {config.credentials.gateway_url}")
487
+ if live and live.get("gateway"):
488
+ print(f"gateway: {live['gateway']}")
489
+ else:
490
+ print(
491
+ f"gateway: {chisel.gateway(config.credentials)}"
492
+ " (from this shell)"
493
+ )
309
494
  else:
310
495
  print("runtime: not connected")
311
496
  if config.mode == "direct":
497
+ print(f"sessions at once: {config.sessions or 'no cap'}")
312
498
  pending = system_link_commands(config.root)
313
499
  print("system links: " + ("ok" if not pending else "missing"))
314
500
  print(
315
501
  f"chisel: {os.environ.get('CHISEL_BIN') or config.root / 'bin' / 'chisel'}"
316
502
  )
317
503
  sessions = manager.store.all()
318
- print(
319
- f"sessions: {len(sessions)} ({len(manager.active_sessions())} active)"
320
- )
504
+ if live:
505
+ active = set(live.get("active_sessions") or [])
506
+ else:
507
+ active = {m.sid for m in sessions if m.state == "active"}
508
+ print(f"sessions: {len(sessions)} ({len(active)} active)")
321
509
  for meta in sessions:
322
- print(f" {meta.sid} {meta.state} {meta.workspace_dir}")
510
+ state = "active" if meta.sid in active else "paused"
511
+ print(f" {meta.sid} {state} {meta.workspace_dir}")
323
512
 
324
513
 
325
514
  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, ``None`` for no cap.
86
+ # Unless it is one, each node runs under bubblewrap with its
87
+ # session's folders at /workspace, /recipes/active and /sdk/active.
88
+ sessions: int | None = 1
85
89
  credentials: Credentials | None = None
86
90
 
87
91
  @property
@@ -97,6 +101,11 @@ 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
+ # Absent (an older runner wrote the file) means one; an
105
+ # explicit null means no cap.
106
+ sessions=None
107
+ if (count := raw.get("sessions", 1)) is None
108
+ else max(1, int(count or 1)),
100
109
  credentials=Credentials.from_dict(creds) if creds else None,
101
110
  )
102
111
 
@@ -106,6 +115,7 @@ class RuntimeConfig:
106
115
  "mode": self.mode,
107
116
  "image": self.image,
108
117
  "share_claude_login": self.share_claude_login,
118
+ "sessions": self.sessions,
109
119
  "credentials": self.credentials.to_dict()
110
120
  if self.credentials
111
121
  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)
@@ -50,6 +50,35 @@ def refresh_connection(creds: Credentials) -> bool:
50
50
  return True
51
51
 
52
52
 
53
+ def post_event(
54
+ creds: Credentials,
55
+ event_type: str,
56
+ payload: Any,
57
+ agent: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ """``POST /api/v1/runtimes/{id}/events``; returns the response body.
60
+
61
+ Raises ``httpx.HTTPStatusError`` with the API's message on refusal.
62
+ """
63
+ body: dict[str, Any] = {"event_type": event_type, "payload": payload}
64
+ if agent is not None:
65
+ body["agent"] = agent
66
+ with _client() as client:
67
+ resp = client.post(
68
+ f"{creds.api_url}/api/v1/runtimes/{creds.runtime_id}/events",
69
+ json=body,
70
+ headers={"Authorization": f"Bearer {creds.secret}"},
71
+ )
72
+ if resp.status_code >= 400:
73
+ raise httpx.HTTPStatusError(
74
+ _error_message(resp), request=resp.request, response=resp
75
+ )
76
+ data = resp.json()
77
+ if not isinstance(data, dict):
78
+ raise ValueError("unexpected response from the platform")
79
+ return data
80
+
81
+
53
82
  def register_runtime(
54
83
  api_url: str, api_key: str, body: dict[str, Any]
55
84
  ) -> dict[str, Any]:
@@ -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,25 @@ 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.
210
+
211
+ With a capacity of one, the active session owns the root-level
212
+ ``/workspace`` link. Above one, or with no cap (``None``), each
213
+ node runs under bubblewrap,
214
+ which binds the session's own folders over ``/workspace``,
215
+ ``/recipes/active`` and ``/sdk/active`` in a private mount
216
+ namespace; the rest of the host looks unchanged.
217
+ """
198
218
 
199
- def __init__(self, root: Path) -> None:
219
+ def __init__(self, root: Path, *, capacity: int | None = 1) -> None:
200
220
  self.root = root
201
- self.active: str | None = None
221
+ self.capacity = None if capacity is None else max(1, capacity)
222
+ # Holders, oldest first.
223
+ self.active: list[str] = []
224
+
225
+ @property
226
+ def sandboxed(self) -> bool:
227
+ return self.capacity != 1
202
228
 
203
229
  @property
204
230
  def idle_workspace(self) -> Path:
@@ -212,24 +238,81 @@ class DirectBackend:
212
238
  os.replace(tmp, link)
213
239
 
214
240
  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
241
+ if meta.sid not in self.active:
242
+ if self.capacity is not None and len(self.active) >= self.capacity:
243
+ raise RuntimeBusy(
244
+ f"runtime full ({self.capacity} sessions)"
245
+ if self.sandboxed
246
+ else "runtime busy",
247
+ session_id=self.active[0],
248
+ )
249
+ self.active.append(meta.sid)
250
+ if not self.sandboxed:
251
+ self._point_workspace(Path(meta.workspace_dir))
219
252
 
220
253
  async def release(self, meta: SessionMeta) -> None:
221
- if self.active == meta.sid:
222
- self.active = None
254
+ if meta.sid not in self.active:
255
+ return
256
+ self.active.remove(meta.sid)
257
+ if not self.sandboxed:
223
258
  self._point_workspace(self.idle_workspace)
224
259
 
225
260
  async def destroy(self, meta: SessionMeta) -> None:
226
261
  await self.release(meta)
227
262
 
228
263
  def status(self, meta: SessionMeta) -> State:
229
- return "active" if self.active == meta.sid else "paused"
264
+ return "active" if meta.sid in self.active else "paused"
265
+
266
+ def views(self, meta: SessionMeta) -> dict[str, str]:
267
+ """Node-visible path -> the session's folder behind it."""
268
+ return {WORKSPACE_PATH: meta.workspace_dir, **meta.trees}
269
+
270
+ def session_path(self, meta: SessionMeta, path: str) -> Path:
271
+ """Where ``path`` lives on the host for this session.
272
+
273
+ Only differs from ``path`` in sandboxed mode, where the views
274
+ exist inside each node's mount namespace.
275
+ """
276
+ if self.sandboxed:
277
+ for view, real in self.views(meta).items():
278
+ if path == view or path.startswith(view + "/"):
279
+ return Path(real + path[len(view) :])
280
+ return Path(path)
281
+
282
+ def _tmp_dir(self, meta: SessionMeta) -> Path:
283
+ return self.root / "sessions" / meta.sid / "tmp"
230
284
 
231
285
  def _env(self, meta: SessionMeta, extra: dict[str, str]) -> dict[str, str]:
232
- return {**host_env(), **meta.env, **extra}
286
+ env = {**host_env(), **meta.env, **extra}
287
+ if self.sandboxed:
288
+ # Daemons outside the sandbox (docker, a devcontainer CLI)
289
+ # resolve paths on the host, so recipes hand them this.
290
+ env["LEVAIN_HOST_WORKSPACE"] = meta.workspace_dir
291
+ # /tmp stays shared (host tools keep sockets there); the
292
+ # session's own scratch keeps file names from clashing.
293
+ tmp = self._tmp_dir(meta)
294
+ tmp.mkdir(parents=True, exist_ok=True)
295
+ env["TMPDIR"] = str(tmp)
296
+ return env
297
+
298
+ def argv(self, meta: SessionMeta, cmd: str) -> list[str]:
299
+ shell = ["/bin/sh", "-c", cmd]
300
+ if not self.sandboxed:
301
+ return shell
302
+ argv = [BWRAP, "--dev-bind", "/", "/"]
303
+ for view, real in self.views(meta).items():
304
+ # bubblewrap mounts on the resolved host path, never on a
305
+ # symlink; ``init`` made these real directories.
306
+ argv += ["--bind", real, os.path.realpath(view)]
307
+ argv += ["--chdir", WORKSPACE_PATH, "--die-with-parent", "--", *shell]
308
+ return argv
309
+
310
+ def _cwd(self, meta: SessionMeta) -> str | None:
311
+ if self.sandboxed:
312
+ return None
313
+ return (
314
+ meta.workspace_dir if Path(meta.workspace_dir).is_dir() else None
315
+ )
233
316
 
234
317
  def exec(
235
318
  self,
@@ -241,13 +324,11 @@ class DirectBackend:
241
324
  timeout: float,
242
325
  ) -> AsyncIterator[dict[str, Any]]:
243
326
  return stream_process(
244
- ["/bin/sh", "-c", cmd],
327
+ self.argv(meta, cmd),
245
328
  env=self._env(meta, env),
246
329
  stdin=stdin.encode() if stdin is not None else None,
247
330
  timeout=timeout,
248
- cwd=meta.workspace_dir
249
- if Path(meta.workspace_dir).is_dir()
250
- else None,
331
+ cwd=self._cwd(meta),
251
332
  )
252
333
 
253
334
  async def capture(
@@ -259,33 +340,37 @@ class DirectBackend:
259
340
  timeout: float,
260
341
  ) -> tuple[str, int]:
261
342
  rc, out, _ = await run_process(
262
- ["/bin/sh", "-c", cmd],
343
+ self.argv(meta, cmd),
263
344
  env=self._env(meta, env),
264
345
  timeout=timeout,
265
- cwd=meta.workspace_dir
266
- if Path(meta.workspace_dir).is_dir()
267
- else None,
346
+ cwd=self._cwd(meta),
268
347
  )
269
348
  return out.decode("utf-8", "replace"), rc
270
349
 
271
350
  async def put_file(
272
351
  self, meta: SessionMeta, path: str, data: bytes
273
352
  ) -> None:
274
- p = Path(path)
353
+ p = self.session_path(meta, path)
275
354
  p.parent.mkdir(parents=True, exist_ok=True)
276
355
  p.write_bytes(data)
277
356
 
278
357
  async def get_file(self, meta: SessionMeta, path: str) -> bytes | None:
279
- p = Path(path)
358
+ p = self.session_path(meta, path)
280
359
  return p.read_bytes() if p.is_file() else None
281
360
 
282
361
  async def put_tree(
283
362
  self, meta: SessionMeta, path: str, src: IO[bytes]
284
363
  ) -> None:
285
- extract_tar(src, Path(path))
364
+ extract_tar(src, self.session_path(meta, path))
286
365
 
287
366
  async def link(self, meta: SessionMeta, path: str, target: str) -> None:
288
- flip_link(Path(path), target, exists_at=Path(target))
367
+ if not self.sandboxed:
368
+ flip_link(Path(path), target, exists_at=Path(target))
369
+ return
370
+ # The view is per session: recorded here, bound at each exec.
371
+ if not Path(target).is_dir():
372
+ raise MissingTarget(f"{target} is not on this runtime")
373
+ meta.trees[path] = target
289
374
 
290
375
 
291
376
  class DockerBackend:
@@ -552,9 +637,10 @@ class DockerBackend:
552
637
  class SessionManager:
553
638
  """Routes session operations to the configured backend."""
554
639
 
555
- def __init__(self, config: RuntimeConfig) -> None:
640
+ def __init__(self, config: RuntimeConfig, *, restore: bool = True) -> None:
556
641
  self.config = config
557
642
  self.store = SessionStore(config.root)
643
+ self.jobs = JobRegistry()
558
644
  self.backend: DirectBackend | DockerBackend
559
645
  if config.mode == "docker":
560
646
  if not config.image:
@@ -565,14 +651,15 @@ class SessionManager:
565
651
  share_claude_login=config.share_claude_login,
566
652
  )
567
653
  else:
568
- self.backend = DirectBackend(config.root)
569
- self._restore_direct_slot()
654
+ self.backend = DirectBackend(config.root, capacity=config.sessions)
655
+ if restore:
656
+ self._restore_direct_slots()
570
657
 
571
- def _restore_direct_slot(self) -> None:
658
+ def _restore_direct_slots(self) -> None:
572
659
  assert isinstance(self.backend, DirectBackend)
573
660
  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.
661
+ # Slots live in memory; after a restart, any recorded holder is
662
+ # stale until the platform resumes it.
576
663
  for meta in active:
577
664
  meta.state = "paused"
578
665
  self.store.save(meta)
@@ -587,7 +674,16 @@ class SessionManager:
587
674
  raise UnknownSession(sid)
588
675
  return meta
589
676
 
677
+ @property
678
+ def capacity(self) -> int | None:
679
+ """Sessions served at once; ``None`` when nothing bounds it."""
680
+ if isinstance(self.backend, DirectBackend):
681
+ return self.backend.capacity
682
+ return None
683
+
590
684
  def active_sessions(self) -> list[str]:
685
+ if isinstance(self.backend, DirectBackend):
686
+ return list(self.backend.active)
591
687
  return [m.sid for m in self.store.all() if m.state == "active"]
592
688
 
593
689
  async def provision(
@@ -648,6 +744,7 @@ class SessionManager:
648
744
  meta = self.store.get(sid)
649
745
  if meta is None:
650
746
  return
747
+ await self.jobs.cancel_for(sid)
651
748
  await self.backend.destroy(meta)
652
749
  self.store.delete(sid, keep_workspace=keep_workspace)
653
750
 
@@ -663,6 +760,26 @@ class SessionManager:
663
760
  meta = self.require(sid)
664
761
  return self.backend.exec(meta, cmd, env, stdin=stdin, timeout=timeout)
665
762
 
763
+ def start_job(
764
+ self,
765
+ sid: str,
766
+ cmd: str,
767
+ env: dict[str, str],
768
+ *,
769
+ stdin: str | None,
770
+ timeout: float,
771
+ ) -> Job:
772
+ return self.jobs.start(
773
+ sid, self.exec(sid, cmd, env, stdin=stdin, timeout=timeout)
774
+ )
775
+
776
+ def job(self, sid: str, job_id: str) -> Job:
777
+ self.require(sid)
778
+ job = self.jobs.get(sid, job_id)
779
+ if job is None:
780
+ raise UnknownJob(job_id)
781
+ return job
782
+
666
783
  async def capture(
667
784
  self, sid: str, cmd: str, env: dict[str, str], *, timeout: float
668
785
  ) -> tuple[str, int]:
@@ -685,11 +802,11 @@ class SessionManager:
685
802
  )
686
803
 
687
804
  async def link(self, sid: str, path: str, target: str) -> None:
805
+ meta = self.require(sid)
688
806
  await self.backend.link(
689
- self.require(sid),
690
- validate_abs_path(path),
691
- validate_abs_path(target),
807
+ meta, validate_abs_path(path), validate_abs_path(target)
692
808
  )
809
+ self.store.save(meta)
693
810
 
694
811
  async def reset_agent_workspace(
695
812
  self, agent_id: str, *, user: str | None
@@ -702,20 +819,28 @@ class SessionManager:
702
819
  scope="user" if user else "agent",
703
820
  user=user,
704
821
  )
822
+ live = set(self.active_sessions())
705
823
  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}")
824
+ if meta.workspace_dir == str(target) and (
825
+ meta.state == "active" or meta.sid in live
826
+ ):
827
+ raise RuntimeBusy(
828
+ f"workspace in use by session {meta.sid}",
829
+ session_id=meta.sid,
830
+ )
708
831
  shutil.rmtree(target, ignore_errors=True)
709
832
  target.mkdir(parents=True, exist_ok=True)
710
833
 
711
834
 
712
835
  __all__ = [
836
+ "BWRAP",
713
837
  "DirectBackend",
714
838
  "DockerBackend",
715
839
  "MissingTarget",
716
840
  "RuntimeBusy",
717
841
  "SessionManager",
718
842
  "SessionMeta",
843
+ "UnknownJob",
719
844
  "UnknownSession",
720
845
  "validate_abs_path",
721
846
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "levain-runtime"
3
- version = "0.1.2"
3
+ version = "0.1.10"
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