sbxloop 0.2.0__tar.gz → 0.3.0__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 (41) hide show
  1. {sbxloop-0.2.0 → sbxloop-0.3.0}/PKG-INFO +2 -2
  2. {sbxloop-0.2.0 → sbxloop-0.3.0}/pyproject.toml +2 -2
  3. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/__init__.py +1 -1
  4. sbxloop-0.3.0/src/sbxloop/_vendor/sbxloop_worker-0.3.0-py3-none-any.whl +0 -0
  5. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/app.py +103 -5
  6. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/tui.py +1 -1
  7. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/engine.py +63 -6
  8. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/model.py +22 -0
  9. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/phases.py +15 -2
  10. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/execute.md +5 -1
  11. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/store.py +33 -14
  12. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/events.py +1 -0
  13. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/pair.py +12 -1
  14. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/provision.py +62 -1
  15. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/sandbox.py +3 -0
  16. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/worker/client.py +4 -0
  17. sbxloop-0.2.0/src/sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
  18. {sbxloop-0.2.0 → sbxloop-0.3.0}/.gitignore +0 -0
  19. {sbxloop-0.2.0 → sbxloop-0.3.0}/README.md +0 -0
  20. {sbxloop-0.2.0 → sbxloop-0.3.0}/hatch_build.py +0 -0
  21. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/__init__.py +0 -0
  22. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/doctor.py +0 -0
  23. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/config.py +0 -0
  24. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/__init__.py +0 -0
  25. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/__init__.py +0 -0
  26. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/decompose.md +0 -0
  27. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/plan.md +0 -0
  28. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  29. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/validate.md +0 -0
  30. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/errors.py +0 -0
  31. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/__init__.py +0 -0
  32. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/ops.py +0 -0
  33. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/reporter.py +0 -0
  34. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/ids.py +0 -0
  35. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/py.typed +0 -0
  36. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/__init__.py +0 -0
  37. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/cli.py +0 -0
  38. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/models.py +0 -0
  39. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/parse.py +0 -0
  40. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/worker/__init__.py +0 -0
  41. {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Requires-Dist: pydantic>=2.7
20
20
  Requires-Dist: python-dotenv>=1.0
21
21
  Requires-Dist: rich>=13.7
22
- Requires-Dist: sbxloop-worker==0.2.0
22
+ Requires-Dist: sbxloop-worker==0.3.0
23
23
  Requires-Dist: typer>=0.12
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sbxloop"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "python-dotenv>=1.0",
22
22
  "typer>=0.12",
23
23
  "rich>=13.7",
24
- "sbxloop-worker==0.2.0",
24
+ "sbxloop-worker==0.3.0",
25
25
  ]
26
26
 
27
27
  [project.scripts]
@@ -6,7 +6,7 @@ agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
6
6
  user-facing GitHub interactions. The balanced network policy is the default.
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.3.0"
10
10
 
11
11
  from sbxloop.config import Budgets, Config, load_config
12
12
  from sbxloop.engine import LoopEngine, RunResult, run_outcome
@@ -11,13 +11,14 @@ import typer
11
11
  from rich.console import Console
12
12
  from rich.live import Live
13
13
  from rich.table import Table
14
+ from rich.tree import Tree
14
15
 
15
16
  import sbxloop
16
17
  from sbxloop.cli.doctor import run_doctor
17
18
  from sbxloop.cli.tui import Dashboard, format_event, plain_printer
18
19
  from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
19
20
  from sbxloop.engine.engine import LoopEngine
20
- from sbxloop.engine.model import RunResult
21
+ from sbxloop.engine.model import RunResult, artifacts_dir
21
22
  from sbxloop.engine.store import StateStore
22
23
  from sbxloop.errors import SdxloopError
23
24
  from sbxloop.sbx.cli import SbxCLI
@@ -93,11 +94,64 @@ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
93
94
  return outcome["result"] # type: ignore[no-any-return]
94
95
 
95
96
 
96
- def _finish(result: RunResult) -> None:
97
+ _TREE_MAX_FILES = 50
98
+
99
+
100
+ def _human_size(size: int) -> str:
101
+ value = float(size)
102
+ for unit in ("B", "KB", "MB"):
103
+ if value < 1024 or unit == "MB":
104
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
105
+ value /= 1024
106
+ raise AssertionError("unreachable") # pragma: no cover
107
+
108
+
109
+ def _artifact_files(root: Path) -> list[Path]:
110
+ """Regular files under root, hidden files/dirs excluded (an agent's .git
111
+ would otherwise swamp the listing), sorted for stable output."""
112
+ return sorted(
113
+ p
114
+ for p in root.rglob("*")
115
+ if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts)
116
+ )
117
+
118
+
119
+ def _artifacts_tree(root: Path, files: list[Path], cap: int = _TREE_MAX_FILES) -> Tree:
120
+ tree = Tree(f"[bold]{root}[/]")
121
+ nodes: dict[Path, Tree] = {root: tree}
122
+ for path in files[:cap]:
123
+ rel = path.relative_to(root)
124
+ parent = root
125
+ for part in rel.parts[:-1]:
126
+ child = parent / part
127
+ if child not in nodes:
128
+ nodes[child] = nodes[parent].add(f"{part}/")
129
+ parent = child
130
+ nodes[parent].add(f"{rel.name} [dim]({_human_size(path.stat().st_size)})[/]")
131
+ if len(files) > cap:
132
+ tree.add(f"[dim]… +{len(files) - cap} more[/]")
133
+ return tree
134
+
135
+
136
+ def _print_artifacts_summary(result: RunResult, config: Config) -> None:
137
+ target = artifacts_dir(result, config.state_dir)
138
+ if target is None or not target.is_dir():
139
+ return
140
+ files = _artifact_files(target)
141
+ if not files:
142
+ console.print(f"\nartifacts: none produced (workspace: {target})")
143
+ return
144
+ via = "live workspace mount" if result.mounted else "harvested from the sandbox"
145
+ console.print(f"\nartifacts: {len(files)} file(s), {via}")
146
+ console.print(_artifacts_tree(target, files))
147
+
148
+
149
+ def _finish(result: RunResult, config: Config) -> None:
97
150
  style = "green" if result.succeeded else "red"
98
151
  console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
99
152
  for task in result.tasks:
100
153
  console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
154
+ _print_artifacts_summary(result, config)
101
155
  raise typer.Exit(0 if result.succeeded else 1)
102
156
 
103
157
 
@@ -125,7 +179,7 @@ def run(
125
179
  except SdxloopError as exc:
126
180
  console.print(f"[bold red]run failed:[/] {exc}")
127
181
  raise typer.Exit(2) from exc
128
- _finish(result)
182
+ _finish(result, config)
129
183
 
130
184
 
131
185
  @app.command()
@@ -134,13 +188,14 @@ def resume(
134
188
  tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
135
189
  ) -> None:
136
190
  """Resume an unfinished run (fresh sandboxes, persisted state)."""
137
- engine = LoopEngine(load_config())
191
+ config = load_config()
192
+ engine = LoopEngine(config)
138
193
  try:
139
194
  result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
140
195
  except SdxloopError as exc:
141
196
  console.print(f"[bold red]resume failed:[/] {exc}")
142
197
  raise typer.Exit(2) from exc
143
- _finish(result)
198
+ _finish(result, config)
144
199
 
145
200
 
146
201
  @app.command()
@@ -229,6 +284,49 @@ def logs(
229
284
  time.sleep(0.5)
230
285
 
231
286
 
287
+ @app.command()
288
+ def artifacts(
289
+ run_id: Annotated[str, typer.Argument(help="Run id.")],
290
+ path: Annotated[
291
+ bool, typer.Option("--path", help="Print only the artifacts directory (for scripting).")
292
+ ] = False,
293
+ tree: Annotated[
294
+ bool, typer.Option("--tree/--list", help="Render a file tree instead of a flat list.")
295
+ ] = False,
296
+ ) -> None:
297
+ """Show where a run's artifacts live on the host, and what is in there."""
298
+ config = load_config()
299
+ store = _store(config)
300
+ try:
301
+ record = store.get_run(run_id)
302
+ except SdxloopError as exc:
303
+ console.print(f"[bold red]{exc}[/]")
304
+ raise typer.Exit(2) from exc
305
+ target = artifacts_dir(record, config.state_dir)
306
+ if target is None:
307
+ console.print(
308
+ f"[bold red]run {run_id} has no artifacts:[/] it never provisioned a workspace "
309
+ f"(state: {record.state})"
310
+ )
311
+ raise typer.Exit(2)
312
+ if path:
313
+ # bare path on stdout, nothing else — `cd $(sbxloop artifacts R --path)`
314
+ typer.echo(str(target))
315
+ return
316
+ if not target.is_dir():
317
+ console.print(f"[bold red]artifacts directory is gone:[/] {target}")
318
+ raise typer.Exit(2)
319
+ files = _artifact_files(target)
320
+ via = "live workspace mount" if record.mounted else "harvested copy"
321
+ console.print(f"run [bold cyan]{run_id}[/]: {len(files)} file(s) ({via}) in [bold]{target}[/]")
322
+ if tree:
323
+ console.print(_artifacts_tree(target, files))
324
+ return
325
+ for file in files:
326
+ size = _human_size(file.stat().st_size)
327
+ console.print(f" {file.relative_to(target)} [dim]{size}[/]")
328
+
329
+
232
330
  @sandbox_app.command("ls")
233
331
  def sandbox_ls() -> None:
234
332
  """List sbxloop-managed sandboxes."""
@@ -187,7 +187,7 @@ def format_event(event: Event) -> str:
187
187
  parts = [_stamp(event), event.type]
188
188
  if event.data.get("task_id"):
189
189
  parts.append(f"[{event.data['task_id']}]")
190
- for key in ("state", "content", "tool", "op", "line", "message", "outcome"):
190
+ for key in ("state", "content", "tool", "op", "line", "message", "outcome", "path"):
191
191
  if event.data.get(key):
192
192
  value = str(event.data[key]).replace("\n", " ")
193
193
  parts.append(value[:160])
@@ -15,6 +15,7 @@ Failure semantics:
15
15
  from __future__ import annotations
16
16
 
17
17
  import json
18
+ import logging
18
19
  import time
19
20
  from collections.abc import Callable, Sequence
20
21
 
@@ -28,15 +29,18 @@ from sbxloop.engine.model import (
28
29
  )
29
30
  from sbxloop.engine.phases import PhaseRunner, clip
30
31
  from sbxloop.engine.store import StateStore
31
- from sbxloop.errors import BudgetExceededError, SdxloopError, StateError
32
+ from sbxloop.errors import BudgetExceededError, SbxError, SdxloopError, StateError
32
33
  from sbxloop.events import EventBus, Hook, HostEventTypes
33
34
  from sbxloop.gh.ops import GithubOps
34
35
  from sbxloop.gh.reporter import GithubReporterHook
35
36
  from sbxloop.ids import new_run_id
36
37
  from sbxloop.sbx.cli import SbxCLI
38
+ from sbxloop.sbx.pair import SandboxPair
37
39
  from sbxloop.sbx.provision import Provisioner
38
40
  from sbxloop.worker.client import WorkerClient
39
41
 
42
+ logger = logging.getLogger(__name__)
43
+
40
44
 
41
45
  class LoopEngine:
42
46
  def __init__(
@@ -101,6 +105,8 @@ class LoopEngine:
101
105
  self._set_run_state(run_id, "provisioning")
102
106
  provisioner = Provisioner(self.sbx, self.config, self.bus)
103
107
  pair = provisioner.ensure_pair(run_id)
108
+ assert pair.workspace is not None
109
+ self.store.set_run_workspace(run_id, pair.workspace, pair.mounted)
104
110
  try:
105
111
  with pair:
106
112
  agent = WorkerClient(
@@ -120,17 +126,29 @@ class LoopEngine:
120
126
  github.install(extras="")
121
127
  detach = self._attach_reporter(github, run_id)
122
128
  try:
123
- phases = PhaseRunner(agent, self.config, run_id, outcome)
124
- state = self._run_phases(run_id, phases, deadline)
129
+ phases = PhaseRunner(
130
+ agent, self.config, run_id, outcome, workdir=pair.agent_workdir
131
+ )
132
+ state = self._run_phases(run_id, phases, deadline, pair)
125
133
  finally:
126
134
  detach()
135
+ # Harvest even when a phase raised: the sandbox is still
136
+ # alive here, and partial artifacts beat none.
137
+ self._harvest(run_id, pair)
138
+ self._report_artifacts(run_id, pair)
127
139
  except SdxloopError:
128
140
  # State is already persisted; the exception is the kill signal.
129
141
  raise
130
142
  self._set_run_state(run_id, state)
131
143
  tasks = self.store.get_tasks(run_id)
132
144
  self.bus.emit(HostEventTypes.RUN_END, run_id, state=state)
133
- return RunResult(run_id=run_id, state=state, tasks=tasks)
145
+ return RunResult(
146
+ run_id=run_id,
147
+ state=state,
148
+ tasks=tasks,
149
+ workspace=pair.workspace,
150
+ mounted=pair.mounted,
151
+ )
134
152
 
135
153
  def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
136
154
  repo = self.config.github.report_repo
@@ -139,7 +157,42 @@ class LoopEngine:
139
157
  hook = GithubReporterHook(GithubOps(github, run_id), repo)
140
158
  return self.bus.attach_hook(hook)
141
159
 
142
- def _run_phases(self, run_id: str, phases: PhaseRunner, deadline: float) -> RunState:
160
+ def _harvest(self, run_id: str, pair: SandboxPair) -> None:
161
+ """Copy the in-VM work dir out to the host (unmounted runs only).
162
+
163
+ Best-effort by design: a failed copy must never fail the run. The
164
+ trailing ``/.`` copies directory *contents* (docker-style cp) — real
165
+ sbx cp directory semantics are e2e-validated.
166
+ """
167
+ if pair.mounted:
168
+ return
169
+ target = self.config.state_dir / "runs" / run_id / "artifacts"
170
+ target.mkdir(parents=True, exist_ok=True)
171
+ try:
172
+ pair.agent.cp_out(f"{pair.agent_workdir}/.", target)
173
+ except SbxError:
174
+ logger.warning("artifact harvest failed for run %s", run_id, exc_info=True)
175
+
176
+ def _report_artifacts(self, run_id: str, pair: SandboxPair) -> None:
177
+ target = (
178
+ pair.workspace
179
+ if pair.mounted
180
+ else self.config.state_dir / "runs" / run_id / "artifacts"
181
+ )
182
+ if target is None or not target.is_dir():
183
+ return
184
+ count = sum(1 for p in target.rglob("*") if p.is_file())
185
+ self.bus.emit(
186
+ HostEventTypes.RUN_ARTIFACTS,
187
+ run_id,
188
+ path=str(target),
189
+ files=count,
190
+ mounted=pair.mounted,
191
+ )
192
+
193
+ def _run_phases(
194
+ self, run_id: str, phases: PhaseRunner, deadline: float, pair: SandboxPair
195
+ ) -> RunState:
143
196
  tasks = self.store.get_tasks(run_id)
144
197
  if not tasks:
145
198
  self._set_run_state(run_id, "decomposing")
@@ -177,7 +230,7 @@ class LoopEngine:
177
230
  self.store.update_task(run_id, task)
178
231
  self._emit_task_end(run_id, task)
179
232
  continue
180
- self._run_task(run_id, phases, task, deadline)
233
+ self._run_task(run_id, phases, task, deadline, pair)
181
234
  if task.state == "failed":
182
235
  failed_ids.add(task.spec.id)
183
236
 
@@ -192,6 +245,7 @@ class LoopEngine:
192
245
  phases: PhaseRunner,
193
246
  task: TaskRecord,
194
247
  deadline: float,
248
+ pair: SandboxPair,
195
249
  ) -> None:
196
250
  budgets = self.config.budgets
197
251
  if task.state == "pending":
@@ -221,6 +275,9 @@ class LoopEngine:
221
275
  else: # pragma: no cover - defensive
222
276
  raise StateError(f"task {task.spec.id} in unexpected state {task.state}")
223
277
 
278
+ # Task-boundary harvest narrows the loss window on long runs; the
279
+ # finalize harvest in _drive remains the authoritative sweep.
280
+ self._harvest(run_id, pair)
224
281
  self._emit_task_end(run_id, task)
225
282
 
226
283
  def _phase_plan(self, run_id: str, phases: PhaseRunner, task: TaskRecord) -> None:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from graphlib import CycleError, TopologicalSorter
6
+ from pathlib import Path
6
7
  from typing import Literal
7
8
 
8
9
  from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -137,6 +138,10 @@ class RunRecord(_Model):
137
138
  state: RunState
138
139
  created_at: float
139
140
  updated_at: float
141
+ # Host workspace directory and whether it was live-mounted into the
142
+ # agent VM (False → artifacts are harvested to runs/<run>/artifacts).
143
+ workspace: Path | None = None
144
+ mounted: bool = False
140
145
 
141
146
 
142
147
  class RunResult(_Model):
@@ -145,7 +150,24 @@ class RunResult(_Model):
145
150
  run_id: str
146
151
  state: RunState
147
152
  tasks: list[TaskRecord] = Field(default_factory=list)
153
+ workspace: Path | None = None
154
+ mounted: bool = False
148
155
 
149
156
  @property
150
157
  def succeeded(self) -> bool:
151
158
  return self.state == "completed"
159
+
160
+
161
+ def artifacts_dir(run: RunRecord | RunResult, state_dir: Path) -> Path | None:
162
+ """The single host directory holding a run's artifacts.
163
+
164
+ Mounted runs write straight into the workspace; unmounted runs are
165
+ harvested to ``runs/<run>/artifacts``. Every reader (run summary,
166
+ ``sbxloop artifacts``, delivery) resolves through here. None means the
167
+ run never got as far as provisioning a workspace.
168
+ """
169
+ if run.workspace is None:
170
+ return None
171
+ if run.mounted:
172
+ return run.workspace
173
+ return state_dir / "runs" / run.run_id / "artifacts"
@@ -48,11 +48,23 @@ def clip(text: str | None, limit: int = OUTPUT_CLIP) -> str:
48
48
  class PhaseRunner:
49
49
  """Runs the six phases for one run against the agent sandbox's worker."""
50
50
 
51
- def __init__(self, agent: WorkerClient, config: Config, run_id: str, outcome: str) -> None:
51
+ def __init__(
52
+ self,
53
+ agent: WorkerClient,
54
+ config: Config,
55
+ run_id: str,
56
+ outcome: str,
57
+ *,
58
+ workdir: str | None = None,
59
+ ) -> None:
52
60
  self.agent = agent
53
61
  self.config = config
54
62
  self.run_id = run_id
55
63
  self.outcome = outcome
64
+ # Canonical in-VM working directory for every job in this run: the
65
+ # discovered workspace mount, or the harvest dir. Evidence and verify
66
+ # commands must run where the executor wrote its files.
67
+ self.workdir = workdir
56
68
 
57
69
  # -- job plumbing ------------------------------------------------------
58
70
 
@@ -71,6 +83,7 @@ class PhaseRunner:
71
83
  model=self.config.model,
72
84
  permission_mode=permission_mode,
73
85
  expect=expect,
86
+ cwd=self.workdir,
74
87
  timeout_s=self.config.budgets.per_job_timeout_s,
75
88
  )
76
89
  result = self.agent.submit(job)
@@ -110,7 +123,7 @@ class PhaseRunner:
110
123
  run_id=self.run_id,
111
124
  kind="shell.check",
112
125
  argv=["sh", "-c", command],
113
- cwd=cwd,
126
+ cwd=cwd or self.workdir,
114
127
  timeout_s=self.config.budgets.per_job_timeout_s,
115
128
  )
116
129
  result = self.agent.submit(job)
@@ -3,7 +3,11 @@
3
3
  You are the execution stage of an automated engineering loop running with full
4
4
  tool access inside an isolated sandbox. Complete the task below by actually
5
5
  doing the work: create and edit files, run commands, and verify as you go.
6
- Work in the current working directory.
6
+
7
+ Work in the current working directory: it is the run's workspace, synced to
8
+ the host so the user receives everything you put in it. Write all outputs and
9
+ artifacts here — files created anywhere else are lost when the sandbox is
10
+ destroyed.
7
11
 
8
12
  ## Overall outcome
9
13
 
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS runs (
31
31
  state TEXT NOT NULL,
32
32
  config_json TEXT NOT NULL DEFAULT '{}',
33
33
  created_at REAL NOT NULL,
34
- updated_at REAL NOT NULL
34
+ updated_at REAL NOT NULL,
35
+ workspace TEXT,
36
+ mounted INTEGER NOT NULL DEFAULT 0
35
37
  );
36
38
  CREATE TABLE IF NOT EXISTS tasks (
37
39
  run_id TEXT NOT NULL,
@@ -68,6 +70,13 @@ CREATE TABLE IF NOT EXISTS events (
68
70
  CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, seq);
69
71
  """
70
72
 
73
+ # Columns added after 0.2.0; applied idempotently so existing state
74
+ # databases upgrade in place on open.
75
+ _RUNS_MIGRATIONS = (
76
+ ("workspace", "ALTER TABLE runs ADD COLUMN workspace TEXT"),
77
+ ("mounted", "ALTER TABLE runs ADD COLUMN mounted INTEGER NOT NULL DEFAULT 0"),
78
+ )
79
+
71
80
 
72
81
  class StateStore:
73
82
  def __init__(self, path: Path) -> None:
@@ -77,6 +86,10 @@ class StateStore:
77
86
  self._conn.row_factory = sqlite3.Row
78
87
  self._conn.execute("PRAGMA journal_mode=WAL")
79
88
  self._conn.executescript(_SCHEMA)
89
+ existing = {row["name"] for row in self._conn.execute("PRAGMA table_info(runs)")}
90
+ for column, ddl in _RUNS_MIGRATIONS:
91
+ if column not in existing:
92
+ self._conn.execute(ddl)
80
93
  self._conn.commit()
81
94
 
82
95
  def close(self) -> None:
@@ -108,30 +121,36 @@ class StateStore:
108
121
  raise StateError(f"unknown run {run_id}")
109
122
  self._conn.commit()
110
123
 
111
- def get_run(self, run_id: str) -> RunRecord:
112
- row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
113
- if row is None:
124
+ def set_run_workspace(self, run_id: str, workspace: Path, mounted: bool) -> None:
125
+ cursor = self._conn.execute(
126
+ "UPDATE runs SET workspace = ?, mounted = ?, updated_at = ? WHERE run_id = ?",
127
+ (str(workspace), int(mounted), time.time(), run_id),
128
+ )
129
+ if cursor.rowcount == 0:
114
130
  raise StateError(f"unknown run {run_id}")
131
+ self._conn.commit()
132
+
133
+ @staticmethod
134
+ def _run_record(row: sqlite3.Row) -> RunRecord:
115
135
  return RunRecord(
116
136
  run_id=row["run_id"],
117
137
  outcome=row["outcome"],
118
138
  state=row["state"],
119
139
  created_at=row["created_at"],
120
140
  updated_at=row["updated_at"],
141
+ workspace=Path(row["workspace"]) if row["workspace"] else None,
142
+ mounted=bool(row["mounted"]),
121
143
  )
122
144
 
145
+ def get_run(self, run_id: str) -> RunRecord:
146
+ row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
147
+ if row is None:
148
+ raise StateError(f"unknown run {run_id}")
149
+ return self._run_record(row)
150
+
123
151
  def list_runs(self) -> list[RunRecord]:
124
152
  rows = self._conn.execute("SELECT * FROM runs ORDER BY created_at DESC").fetchall()
125
- return [
126
- RunRecord(
127
- run_id=row["run_id"],
128
- outcome=row["outcome"],
129
- state=row["state"],
130
- created_at=row["created_at"],
131
- updated_at=row["updated_at"],
132
- )
133
- for row in rows
134
- ]
153
+ return [self._run_record(row) for row in rows]
135
154
 
136
155
  # -- tasks -------------------------------------------------------------
137
156
 
@@ -73,6 +73,7 @@ class HostEventTypes:
73
73
 
74
74
  RUN_START = "run.start"
75
75
  RUN_STATE = "run.state"
76
+ RUN_ARTIFACTS = "run.artifacts"
76
77
  RUN_END = "run.end"
77
78
  TASK_START = "task.start"
78
79
  TASK_STATE = "task.state"
@@ -15,9 +15,10 @@ import logging
15
15
  import signal
16
16
  import threading
17
17
  import types
18
+ from pathlib import Path
18
19
  from typing import Any
19
20
 
20
- from sbxloop.sbx.sandbox import Sandbox
21
+ from sbxloop.sbx.sandbox import WORK_DIR, Sandbox
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
@@ -30,11 +31,21 @@ class SandboxPair:
30
31
  github: Sandbox,
31
32
  *,
32
33
  keep: bool = False,
34
+ workspace: Path | None = None,
35
+ agent_workdir: str = WORK_DIR,
36
+ mounted: bool = False,
33
37
  ) -> None:
34
38
  self.run_id = run_id
35
39
  self.agent = agent
36
40
  self.github = github
37
41
  self.keep = keep
42
+ # The host directory sbx was given as the run workspace, the in-VM
43
+ # working directory agent jobs run in, and whether the two are the
44
+ # same filesystem (mount discovered) — if not, artifacts must be
45
+ # harvested out with `sbx cp`.
46
+ self.workspace = workspace
47
+ self.agent_workdir = agent_workdir
48
+ self.mounted = mounted
38
49
  self._cleaned = False
39
50
 
40
51
  def __enter__(self) -> SandboxPair:
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
  import logging
20
20
  import os
21
21
  import re
22
+ import secrets
22
23
  import shlex
23
24
  from collections.abc import Callable, Mapping
24
25
  from functools import partial
@@ -30,7 +31,7 @@ from sbxloop.events import EventBus
30
31
  from sbxloop.sbx.cli import SbxCLI
31
32
  from sbxloop.sbx.models import SandboxRole, SandboxSpec, SecretSpec
32
33
  from sbxloop.sbx.pair import SandboxPair
33
- from sbxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, Sandbox
34
+ from sbxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, WORK_DIR, Sandbox
34
35
 
35
36
  logger = logging.getLogger(__name__)
36
37
 
@@ -62,6 +63,11 @@ GITHUB_ALLOW_DOMAINS = (
62
63
  "objects.githubusercontent.com",
63
64
  )
64
65
 
66
+ # Candidate roots for the in-VM workspace mount search. Where sbx mounts the
67
+ # host workspace inside the microVM is undocumented; probe, never assume.
68
+ MOUNT_SEARCH_ROOTS = ("/workspace", "/home/agent", "/mnt", "/host", "/root")
69
+ MOUNT_SEARCH_MAXDEPTH = 4
70
+
65
71
  PostCreate = Callable[[Sandbox, SandboxRole], None]
66
72
 
67
73
 
@@ -162,11 +168,19 @@ class Provisioner:
162
168
  self.post_create(sandbox, spec.role)
163
169
  sandboxes[spec.role] = sandbox
164
170
  self.bus.emit("sandbox.ready", run_id, name=spec.name, role=spec.role)
171
+ agent_workdir = self._discover_mount(run_id, sandboxes["agent"], workspace)
172
+ mounted = agent_workdir is not None
173
+ if agent_workdir is None:
174
+ agent_workdir = WORK_DIR
175
+ sandboxes["agent"].mkdirs(agent_workdir)
165
176
  return SandboxPair(
166
177
  run_id,
167
178
  agent=sandboxes["agent"],
168
179
  github=sandboxes["github"],
169
180
  keep=self.config.keep_sandboxes,
181
+ workspace=workspace,
182
+ agent_workdir=agent_workdir,
183
+ mounted=mounted,
170
184
  )
171
185
  except Exception as exc:
172
186
  for sandbox in created:
@@ -345,6 +359,53 @@ class Provisioner:
345
359
  message=message,
346
360
  )
347
361
 
362
+ def _discover_mount(self, run_id: str, sandbox: Sandbox, workspace: Path) -> str | None:
363
+ """Find where sbx mounted the host workspace inside the agent VM.
364
+
365
+ Writes a nonce marker file into the host workspace, then runs one
366
+ bounded in-sandbox search for it over candidate roots. Returns the
367
+ in-VM directory containing the marker, or None when discovery fails
368
+ (→ harvest mode; non-fatal, mirroring _verify_secret_env's
369
+ probe-don't-assume pattern). The marker is always removed.
370
+ """
371
+ marker = f".sbxloop-mount-{secrets.token_hex(8)}"
372
+ try:
373
+ (workspace / marker).write_text("")
374
+ except OSError:
375
+ logger.warning("mount discovery: cannot write marker into %s", workspace)
376
+ return None
377
+ try:
378
+ command = (
379
+ f"find -L {' '.join(MOUNT_SEARCH_ROOTS)} "
380
+ f"-maxdepth {MOUNT_SEARCH_MAXDEPTH} -name {marker} -print 2>/dev/null"
381
+ " | head -1"
382
+ )
383
+ result = sandbox.exec(["sh", "-c", command])
384
+ hit = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
385
+ except SbxError:
386
+ logger.warning("mount discovery failed for %s", sandbox.name, exc_info=True)
387
+ hit = ""
388
+ finally:
389
+ (workspace / marker).unlink(missing_ok=True)
390
+ if not hit.endswith(f"/{marker}"):
391
+ self.bus.emit(
392
+ "sandbox.workspace_mount",
393
+ run_id,
394
+ name=sandbox.name,
395
+ mounted=False,
396
+ message="workspace mount not found in VM; artifacts will be harvested",
397
+ )
398
+ return None
399
+ mount_dir = hit[: -len(f"/{marker}")] or "/"
400
+ self.bus.emit(
401
+ "sandbox.workspace_mount",
402
+ run_id,
403
+ name=sandbox.name,
404
+ mounted=True,
405
+ path=mount_dir,
406
+ )
407
+ return mount_dir
408
+
348
409
  def _apply_plain_env(self, spec: SandboxSpec, sandbox: Sandbox, token: str) -> None:
349
410
  """Weaker fallback: write tokens/env into ~/.sbxloop/env.sh in the VM."""
350
411
  exports: dict[str, str] = dict(spec.persistent_env)
@@ -13,6 +13,9 @@ from sbxloop.sbx.models import ExecResult
13
13
  # Canonical in-sandbox layout. The sandbox user is `agent` in the official
14
14
  # Docker sandbox templates.
15
15
  SANDBOX_HOME = "/home/agent"
16
+ # Fallback in-VM working directory when the host-workspace mount cannot be
17
+ # discovered (harvest mode copies it out with `sbx cp` instead).
18
+ WORK_DIR = f"{SANDBOX_HOME}/work"
16
19
  SBXLOOP_DIR = f"{SANDBOX_HOME}/.sbxloop"
17
20
  JOBS_DIR = f"{SBXLOOP_DIR}/jobs"
18
21
  RESULTS_DIR = f"{SBXLOOP_DIR}/results"
@@ -224,6 +224,10 @@ class WorkerClient:
224
224
  "--env-file",
225
225
  ENV_FILE,
226
226
  ]
227
+ # cwd travels on argv (not only in the job JSON) so the worker
228
+ # process itself chdirs there — agent SDK sessions inherit it.
229
+ if job.cwd:
230
+ argv += ["--cwd", job.cwd]
227
231
  # sbx injects secrets through the sandbox session/profile machinery;
228
232
  # a bare exec'd process may not see them. Run the worker under a
229
233
  # login shell so the sandbox environment is fully loaded.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes