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.
- {sbxloop-0.2.0 → sbxloop-0.3.0}/PKG-INFO +2 -2
- {sbxloop-0.2.0 → sbxloop-0.3.0}/pyproject.toml +2 -2
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/__init__.py +1 -1
- sbxloop-0.3.0/src/sbxloop/_vendor/sbxloop_worker-0.3.0-py3-none-any.whl +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/app.py +103 -5
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/tui.py +1 -1
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/engine.py +63 -6
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/model.py +22 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/phases.py +15 -2
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/execute.md +5 -1
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/store.py +33 -14
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/events.py +1 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/pair.py +12 -1
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/provision.py +62 -1
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/sandbox.py +3 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/worker/client.py +4 -0
- sbxloop-0.2.0/src/sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/.gitignore +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/README.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/hatch_build.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/cli/doctor.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/config.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.3.0}/src/sbxloop/worker/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
124
|
-
|
|
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(
|
|
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
|
|
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__(
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
|
@@ -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.
|
|
Binary file
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|