sbxloop 0.5.5__tar.gz → 0.5.7__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.5.5 → sbxloop-0.5.7}/PKG-INFO +1 -1
- sbxloop-0.5.7/src/sbxloop/_vendor/sbxloop_worker-0.5.7-py3-none-any.whl +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/app.py +78 -1
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/doctor.py +23 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/tui.py +42 -4
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/config.py +43 -1
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/engine.py +122 -34
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/model.py +3 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/events.py +1 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/cli.py +16 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/worker/client.py +22 -1
- sbxloop-0.5.5/src/sbxloop/_vendor/sbxloop_worker-0.5.5-py3-none-any.whl +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/.gitignore +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/README.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/hatch_build.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/pyproject.toml +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/provision.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/worker/wheel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sbxloop
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.7
|
|
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
|
|
Binary file
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.5.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
21
|
+
__version__ = version = '0.5.7'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 7)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -169,6 +169,10 @@ def _finish(result: RunResult, config: Config) -> None:
|
|
|
169
169
|
for task in result.tasks:
|
|
170
170
|
console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
|
|
171
171
|
_print_artifacts_summary(result, config)
|
|
172
|
+
if result.kept_sandboxes:
|
|
173
|
+
console.print(f"\n[bold yellow]sandboxes kept:[/] {', '.join(result.kept_sandboxes)}")
|
|
174
|
+
console.print(f" inspect: [cyan]sbxloop shell {result.run_id}[/] (--role github)")
|
|
175
|
+
console.print(f" remove: [cyan]sbxloop sandbox rm --run {result.run_id}[/]")
|
|
172
176
|
raise typer.Exit(0 if result.succeeded else 1)
|
|
173
177
|
|
|
174
178
|
|
|
@@ -193,10 +197,21 @@ def run(
|
|
|
193
197
|
keep_sandboxes: Annotated[
|
|
194
198
|
bool, typer.Option("--keep-sandboxes", help="Do not remove sandboxes at the end.")
|
|
195
199
|
] = False,
|
|
200
|
+
keep_on_failure: Annotated[
|
|
201
|
+
bool | None,
|
|
202
|
+
typer.Option(
|
|
203
|
+
"--keep-on-failure/--no-keep-on-failure",
|
|
204
|
+
help="Keep the sandbox pair alive when the run fails (inspect with `sbxloop shell`).",
|
|
205
|
+
),
|
|
206
|
+
] = None,
|
|
196
207
|
tui: Annotated[bool, typer.Option("--tui/--no-tui", help="Live dashboard.")] = True,
|
|
197
208
|
) -> None:
|
|
198
209
|
"""Run an agentic loop for OUTCOME in a fresh sandbox pair."""
|
|
199
|
-
config = _config_with_overrides(
|
|
210
|
+
config = _config_with_overrides(
|
|
211
|
+
model=model,
|
|
212
|
+
keep_sandboxes=keep_sandboxes or None,
|
|
213
|
+
keep_on_failure=keep_on_failure,
|
|
214
|
+
)
|
|
200
215
|
if report is not None:
|
|
201
216
|
config = config.model_copy(
|
|
202
217
|
update={"github": config.github.model_copy(update={"report": report})}
|
|
@@ -331,6 +346,57 @@ def logs(
|
|
|
331
346
|
time.sleep(0.5)
|
|
332
347
|
|
|
333
348
|
|
|
349
|
+
# Prefer bash when the template has it, fall back to POSIX sh. `sbx exec`
|
|
350
|
+
# has no documented -it flags; terminal attachment is inherited stdio.
|
|
351
|
+
_INTERACTIVE_SHELL = ("sh", "-c", "command -v bash >/dev/null && exec bash -l; exec sh -l")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command()
|
|
355
|
+
def shell(
|
|
356
|
+
run_id: Annotated[str, typer.Argument(help="Run id.")],
|
|
357
|
+
role: Annotated[
|
|
358
|
+
str, typer.Option("--role", help="Which sandbox of the pair: agent or github.")
|
|
359
|
+
] = "agent",
|
|
360
|
+
command: Annotated[
|
|
361
|
+
str | None,
|
|
362
|
+
typer.Option(
|
|
363
|
+
"--command", "-c", help="Run one shell command instead of an interactive shell."
|
|
364
|
+
),
|
|
365
|
+
] = None,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Open a shell inside a run's sandbox (kept, in-flight, or leaked).
|
|
368
|
+
|
|
369
|
+
Attaching to an in-flight run is meant as observation: the worker owns
|
|
370
|
+
its env files and workspace, so avoid mutating them mid-phase.
|
|
371
|
+
"""
|
|
372
|
+
if role not in ("agent", "github"):
|
|
373
|
+
console.print(f"[bold red]invalid --role {role!r}:[/] must be agent or github")
|
|
374
|
+
raise typer.Exit(2)
|
|
375
|
+
config = load_config()
|
|
376
|
+
store = _store(config)
|
|
377
|
+
try:
|
|
378
|
+
store.get_run(run_id)
|
|
379
|
+
except SdxloopError as exc:
|
|
380
|
+
console.print(f"[bold red]{exc}[/]")
|
|
381
|
+
raise typer.Exit(2) from exc
|
|
382
|
+
cli = SbxCLI(app_name=config.app_name or None)
|
|
383
|
+
name = sandbox_name(run_id, "agent" if role == "agent" else "github")
|
|
384
|
+
try:
|
|
385
|
+
live = any(info.name == name for info in cli.ls())
|
|
386
|
+
except SdxloopError as exc:
|
|
387
|
+
console.print(f"[bold red]{exc}[/]")
|
|
388
|
+
raise typer.Exit(2) from exc
|
|
389
|
+
if not live:
|
|
390
|
+
console.print(
|
|
391
|
+
f"[bold red]sandbox {name} is not running.[/] Sandboxes are removed at run end "
|
|
392
|
+
"unless kept (keep_on_failure, --keep-sandboxes), and kept ones may have been "
|
|
393
|
+
"pruned since."
|
|
394
|
+
)
|
|
395
|
+
raise typer.Exit(2)
|
|
396
|
+
argv = ("sh", "-lc", command) if command else _INTERACTIVE_SHELL
|
|
397
|
+
raise typer.Exit(cli.exec_interactive(name, argv))
|
|
398
|
+
|
|
399
|
+
|
|
334
400
|
@app.command()
|
|
335
401
|
def artifacts(
|
|
336
402
|
run_id: Annotated[str, typer.Argument(help="Run id.")],
|
|
@@ -556,6 +622,8 @@ app_name = ""
|
|
|
556
622
|
state_dir = ".sbxloop"
|
|
557
623
|
# Keep sandboxes around after a run (for debugging).
|
|
558
624
|
keep_sandboxes = false
|
|
625
|
+
# Keep the pair alive only when a run fails; inspect with `sbxloop shell <run>`.
|
|
626
|
+
keep_on_failure = false
|
|
559
627
|
# Worker transport: "stream" (default) or "poll".
|
|
560
628
|
worker_transport = "stream"
|
|
561
629
|
# Secret injection: "proxy" (sbx keychain proxy; recommended) or "plain-env".
|
|
@@ -587,6 +655,15 @@ max_replans_per_task = 1
|
|
|
587
655
|
max_tasks = 20
|
|
588
656
|
max_wall_clock_s = 7200.0
|
|
589
657
|
per_job_timeout_s = 900.0
|
|
658
|
+
|
|
659
|
+
[limits]
|
|
660
|
+
# Sandbox resource guardrails (percent used; 0 disables). Sampled in-VM on
|
|
661
|
+
# the worker heartbeat and shown as a gauge in the TUI status panel.
|
|
662
|
+
# Crossing disk_abort fails the current task with an explicit
|
|
663
|
+
# "sandbox disk exhausted" error.
|
|
664
|
+
disk_warn = 85.0
|
|
665
|
+
disk_abort = 95.0
|
|
666
|
+
mem_warn = 90.0
|
|
590
667
|
"""
|
|
591
668
|
|
|
592
669
|
|
|
@@ -122,6 +122,29 @@ def collect_checks(
|
|
|
122
122
|
)
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
+
# Host-side resource stats: sbx 0.35.x has no stats command, so
|
|
126
|
+
# resource telemetry samples in-VM on the worker heartbeat. This is
|
|
127
|
+
# the field-check for the day sbx grows one (issue #54) — purely
|
|
128
|
+
# informational either way.
|
|
129
|
+
report("checking for host-side sandbox stats")
|
|
130
|
+
stats_available = False
|
|
131
|
+
try:
|
|
132
|
+
stats_available = cli.run("stats", "--help", check=False).returncode == 0
|
|
133
|
+
except SbxError:
|
|
134
|
+
stats_available = False
|
|
135
|
+
checks.append(
|
|
136
|
+
Check(
|
|
137
|
+
"sandbox stats",
|
|
138
|
+
True,
|
|
139
|
+
"host-side `sbx stats` available — sbxloop still samples in-VM; "
|
|
140
|
+
"consider filing an issue to prefer host-side stats"
|
|
141
|
+
if stats_available
|
|
142
|
+
else "no host-side `sbx stats`; resource telemetry samples in-VM "
|
|
143
|
+
f"on the worker heartbeat (expected on {TESTED_SBX_SERIES}.x)",
|
|
144
|
+
hard=False,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
125
148
|
# tokens
|
|
126
149
|
checks.append(
|
|
127
150
|
Check(
|
|
@@ -42,7 +42,9 @@ TASK_STATE_STYLES = {
|
|
|
42
42
|
_LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.")
|
|
43
43
|
|
|
44
44
|
# High-volume noise excluded from the transcript (still queryable via
|
|
45
|
-
# `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats
|
|
45
|
+
# `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats,
|
|
46
|
+
# per-beat resource samples (they live in the pinned gauge instead —
|
|
47
|
+
# threshold *crossings* arrive as sandbox.resources_warning and do print).
|
|
46
48
|
_TRANSCRIPT_SKIP = {
|
|
47
49
|
EventTypes.AGENT_MESSAGE_DELTA,
|
|
48
50
|
EventTypes.WORKER_STDOUT,
|
|
@@ -51,8 +53,11 @@ _TRANSCRIPT_SKIP = {
|
|
|
51
53
|
EventTypes.WORKER_RESULT,
|
|
52
54
|
EventTypes.WORKER_END,
|
|
53
55
|
EventTypes.AGENT_USAGE,
|
|
56
|
+
EventTypes.SANDBOX_RESOURCES,
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
RESOURCE_LEVEL_STYLES = {"ok": "dim", "warn": "yellow", "abort": "bold red"}
|
|
60
|
+
|
|
56
61
|
AGENT_MESSAGE_CLIP = 4000
|
|
57
62
|
# One-line clip for tool arguments; output tail lines shown on tool failure.
|
|
58
63
|
TOOL_ARGS_LINE_CLIP = 160
|
|
@@ -148,7 +153,7 @@ def render_event(event: Event) -> RenderableType | None:
|
|
|
148
153
|
style = "dim"
|
|
149
154
|
if event.type == HostEventTypes.TASK_STATE:
|
|
150
155
|
style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
|
|
151
|
-
elif "fallback" in event.type or "missing" in event.type:
|
|
156
|
+
elif "fallback" in event.type or "missing" in event.type or "warning" in event.type:
|
|
152
157
|
style = "yellow"
|
|
153
158
|
return Text(line, style=style, overflow="fold")
|
|
154
159
|
|
|
@@ -166,13 +171,18 @@ class Dashboard:
|
|
|
166
171
|
self.run_state: str = "starting"
|
|
167
172
|
self.outcome: str = ""
|
|
168
173
|
self.tasks: dict[str, dict[str, Any]] = {}
|
|
174
|
+
# Latest resource sample per sandbox role, rendered as one compact
|
|
175
|
+
# gauge line each; only escalates visually past warn/abort levels.
|
|
176
|
+
self.resources: dict[str, dict[str, Any]] = {}
|
|
169
177
|
|
|
170
178
|
# -- event intake ------------------------------------------------------
|
|
171
179
|
|
|
172
180
|
def on_event(self, event: Event) -> None:
|
|
173
181
|
self.run_id = self.run_id or event.run_id
|
|
174
182
|
data = event.data
|
|
175
|
-
if event.type ==
|
|
183
|
+
if event.type == EventTypes.SANDBOX_RESOURCES:
|
|
184
|
+
self.resources[str(data.get("role") or "sandbox")] = dict(data)
|
|
185
|
+
elif event.type == HostEventTypes.RUN_START:
|
|
176
186
|
self.outcome = str(data.get("outcome", ""))
|
|
177
187
|
elif event.type in (HostEventTypes.RUN_STATE, HostEventTypes.RUN_END):
|
|
178
188
|
self.run_state = str(data.get("state", self.run_state))
|
|
@@ -219,12 +229,29 @@ class Dashboard:
|
|
|
219
229
|
if not self.tasks:
|
|
220
230
|
table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
|
|
221
231
|
|
|
232
|
+
gauges = [self._gauge_line(role) for role in sorted(self.resources)]
|
|
222
233
|
return Panel(
|
|
223
|
-
Group(header, outcome, table),
|
|
234
|
+
Group(header, outcome, table, *gauges),
|
|
224
235
|
border_style="cyan",
|
|
225
236
|
padding=(0, 1),
|
|
226
237
|
)
|
|
227
238
|
|
|
239
|
+
def _gauge_line(self, role: str) -> Text:
|
|
240
|
+
sample = self.resources[role]
|
|
241
|
+
level = str(sample.get("level", "ok"))
|
|
242
|
+
parts = []
|
|
243
|
+
if sample.get("disk_used_pct") is not None:
|
|
244
|
+
parts.append(f"disk {sample['disk_used_pct']:.0f}%")
|
|
245
|
+
if sample.get("mem_used_pct") is not None:
|
|
246
|
+
parts.append(f"mem {sample['mem_used_pct']:.0f}%")
|
|
247
|
+
if sample.get("load1") is not None:
|
|
248
|
+
parts.append(f"load {sample['load1']}")
|
|
249
|
+
suffix = " ⚠ " + level if level != "ok" else ""
|
|
250
|
+
return Text(
|
|
251
|
+
f"{role}: " + " · ".join(parts) + suffix,
|
|
252
|
+
style=RESOURCE_LEVEL_STYLES.get(level, "dim"),
|
|
253
|
+
)
|
|
254
|
+
|
|
228
255
|
|
|
229
256
|
def format_event(event: Event) -> str:
|
|
230
257
|
"""Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
|
|
@@ -239,6 +266,17 @@ def format_event(event: Event) -> str:
|
|
|
239
266
|
value = str(event.data[key]).replace("\n", " ")
|
|
240
267
|
parts.append(value[:160])
|
|
241
268
|
break
|
|
269
|
+
if event.data.get("disk_used_pct") is not None:
|
|
270
|
+
# Resource samples: make `sbxloop logs --type-prefix sandbox.resources`
|
|
271
|
+
# answer "what did disk look like before the failure" at a glance.
|
|
272
|
+
summary = [f"disk={event.data['disk_used_pct']}%"]
|
|
273
|
+
if event.data.get("mem_used_pct") is not None:
|
|
274
|
+
summary.append(f"mem={event.data['mem_used_pct']}%")
|
|
275
|
+
if event.data.get("load1") is not None:
|
|
276
|
+
summary.append(f"load={event.data['load1']}")
|
|
277
|
+
if event.data.get("level"):
|
|
278
|
+
summary.append(str(event.data["level"]))
|
|
279
|
+
parts.append(" ".join(summary))
|
|
242
280
|
if event.data.get("args"):
|
|
243
281
|
parts.append(_one_line(str(event.data["args"]), 120))
|
|
244
282
|
if picked != "error" and event.data.get("error"):
|
|
@@ -18,7 +18,14 @@ from collections.abc import Mapping
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Literal
|
|
20
20
|
|
|
21
|
-
from pydantic import
|
|
21
|
+
from pydantic import (
|
|
22
|
+
BaseModel,
|
|
23
|
+
ConfigDict,
|
|
24
|
+
Field,
|
|
25
|
+
ValidationError,
|
|
26
|
+
field_validator,
|
|
27
|
+
model_validator,
|
|
28
|
+
)
|
|
22
29
|
|
|
23
30
|
from sbxloop.errors import ConfigError
|
|
24
31
|
|
|
@@ -68,6 +75,35 @@ class GithubConfig(_ConfigModel):
|
|
|
68
75
|
return self.repo is not None
|
|
69
76
|
|
|
70
77
|
|
|
78
|
+
class Limits(_ConfigModel):
|
|
79
|
+
"""Sandbox resource guardrails, sampled in-VM on the worker heartbeat.
|
|
80
|
+
|
|
81
|
+
Thresholds are percent-used of the workspace filesystem / memory; 0
|
|
82
|
+
disables a guardrail. Crossing a warn threshold emits a prominent
|
|
83
|
+
``sandbox.resources_warning`` event and escalates the TUI gauge;
|
|
84
|
+
crossing ``disk_abort`` fails the current task with an explicit
|
|
85
|
+
"sandbox disk exhausted" error instead of letting in-VM tooling fail
|
|
86
|
+
confusingly on a full disk.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
disk_warn: float = 85.0
|
|
90
|
+
disk_abort: float = 95.0
|
|
91
|
+
mem_warn: float = 90.0
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def _check_thresholds(self) -> Limits:
|
|
95
|
+
for name in ("disk_warn", "disk_abort", "mem_warn"):
|
|
96
|
+
value = getattr(self, name)
|
|
97
|
+
if value < 0 or value > 100:
|
|
98
|
+
raise ValueError(f"limits.{name} must be a percentage in 0..100, got {value}")
|
|
99
|
+
if 0 < self.disk_abort <= self.disk_warn:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"limits.disk_abort ({self.disk_abort}) must be greater than "
|
|
102
|
+
f"limits.disk_warn ({self.disk_warn})"
|
|
103
|
+
)
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
|
|
71
107
|
class Budgets(_ConfigModel):
|
|
72
108
|
max_revisions_per_task: int = 2
|
|
73
109
|
max_replans_per_task: int = 1
|
|
@@ -85,6 +121,11 @@ class Config(_ConfigModel):
|
|
|
85
121
|
app_name: str = ""
|
|
86
122
|
state_dir: Path = Path(".sbxloop")
|
|
87
123
|
keep_sandboxes: bool = False
|
|
124
|
+
# Keep the pair alive only when a run fails, so the evidence (worker
|
|
125
|
+
# stderr, install leftovers, workspace state) survives for
|
|
126
|
+
# `sbxloop shell <run>`. Kept runs are marked in the state DB and
|
|
127
|
+
# eventually collectable via `sbxloop sandbox prune --include-kept`.
|
|
128
|
+
keep_on_failure: bool = False
|
|
88
129
|
worker_transport: WorkerTransport = "stream"
|
|
89
130
|
secret_strategy: SecretStrategy = "proxy"
|
|
90
131
|
# Advanced: in-sandbox interpreter for the worker, and whether to run the
|
|
@@ -95,6 +136,7 @@ class Config(_ConfigModel):
|
|
|
95
136
|
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
96
137
|
github: GithubConfig = Field(default_factory=GithubConfig)
|
|
97
138
|
budgets: Budgets = Field(default_factory=Budgets)
|
|
139
|
+
limits: Limits = Field(default_factory=Limits)
|
|
98
140
|
|
|
99
141
|
|
|
100
142
|
def _read_toml(path: Path) -> dict[str, Any]:
|
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import logging
|
|
19
19
|
import time
|
|
20
20
|
from collections.abc import Callable, Sequence
|
|
21
|
+
from typing import Any
|
|
21
22
|
|
|
22
23
|
from sbxloop.config import Config, load_config, load_dotenv_file
|
|
23
24
|
from sbxloop.deliver import deliver_workspace
|
|
@@ -75,9 +76,13 @@ class LoopEngine:
|
|
|
75
76
|
install_workers if install_workers is not None else self.config.install_workers
|
|
76
77
|
)
|
|
77
78
|
self.clock = clock
|
|
79
|
+
# Latest sandbox.resources sample per sandbox role, fed by the bus;
|
|
80
|
+
# consulted for the disk guardrail and the harvest-truncation note.
|
|
81
|
+
self._last_resources: dict[str, dict[str, object]] = {}
|
|
78
82
|
for hook in hooks:
|
|
79
83
|
self.bus.attach_hook(hook)
|
|
80
84
|
self.bus.subscribe(self._persist_event)
|
|
85
|
+
self.bus.subscribe(self._track_resources)
|
|
81
86
|
|
|
82
87
|
def _persist_event(self, event: object) -> None:
|
|
83
88
|
from sbxloop_worker.protocol import Event
|
|
@@ -85,6 +90,14 @@ class LoopEngine:
|
|
|
85
90
|
assert isinstance(event, Event)
|
|
86
91
|
self.store.append_event(event)
|
|
87
92
|
|
|
93
|
+
def _track_resources(self, event: object) -> None:
|
|
94
|
+
from sbxloop_worker.protocol import Event, EventTypes
|
|
95
|
+
|
|
96
|
+
assert isinstance(event, Event)
|
|
97
|
+
if event.type == EventTypes.SANDBOX_RESOURCES:
|
|
98
|
+
role = str(event.data.get("role") or "agent")
|
|
99
|
+
self._last_resources[role] = dict(event.data)
|
|
100
|
+
|
|
88
101
|
# -- public API --------------------------------------------------------
|
|
89
102
|
|
|
90
103
|
def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
|
|
@@ -113,49 +126,64 @@ class LoopEngine:
|
|
|
113
126
|
pair = provisioner.ensure_pair(run_id)
|
|
114
127
|
assert pair.workspace is not None
|
|
115
128
|
self.store.set_run_workspace(run_id, pair.workspace, pair.mounted)
|
|
129
|
+
if pair.keep:
|
|
130
|
+
# keep_sandboxes: mark up front so `sandbox prune` respects it.
|
|
131
|
+
self.store.set_run_kept(run_id, "manual")
|
|
116
132
|
try:
|
|
117
133
|
with pair:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
transport=self.config.worker_transport,
|
|
122
|
-
python=self.worker_python,
|
|
123
|
-
)
|
|
124
|
-
github = (
|
|
125
|
-
WorkerClient(
|
|
126
|
-
pair.github,
|
|
134
|
+
try:
|
|
135
|
+
agent = WorkerClient(
|
|
136
|
+
pair.agent,
|
|
127
137
|
self.bus,
|
|
128
138
|
transport=self.config.worker_transport,
|
|
129
139
|
python=self.worker_python,
|
|
140
|
+
role="agent",
|
|
141
|
+
limits=self.config.limits,
|
|
130
142
|
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
phases = PhaseRunner(
|
|
143
|
-
agent, self.config, run_id, outcome, workdir=pair.agent_workdir
|
|
143
|
+
github = (
|
|
144
|
+
WorkerClient(
|
|
145
|
+
pair.github,
|
|
146
|
+
self.bus,
|
|
147
|
+
transport=self.config.worker_transport,
|
|
148
|
+
python=self.worker_python,
|
|
149
|
+
role="github",
|
|
150
|
+
limits=self.config.limits,
|
|
151
|
+
)
|
|
152
|
+
if pair.github is not None
|
|
153
|
+
else None
|
|
144
154
|
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
if self.install_workers:
|
|
156
|
+
# ensure_dev_tools: the agent builds projects in this VM
|
|
157
|
+
# (venvs, pip) — the github sandbox only runs API ops.
|
|
158
|
+
agent.install(extras="copilot", ensure_dev_tools=True)
|
|
159
|
+
if github is not None:
|
|
160
|
+
github.install(extras="")
|
|
161
|
+
reporter, detach = self._attach_reporter(github, run_id, outcome)
|
|
162
|
+
try:
|
|
163
|
+
phases = PhaseRunner(
|
|
164
|
+
agent, self.config, run_id, outcome, workdir=pair.agent_workdir
|
|
165
|
+
)
|
|
166
|
+
state = self._run_phases(run_id, phases, deadline, pair)
|
|
167
|
+
# Summary must post while the github sandbox is alive;
|
|
168
|
+
# on an infra exception the run is resumable and the
|
|
169
|
+
# resumed run reopens the same tracking issue.
|
|
170
|
+
if reporter is not None:
|
|
171
|
+
reporter.close_run(run_id, state)
|
|
172
|
+
finally:
|
|
173
|
+
detach()
|
|
174
|
+
# Harvest even when a phase raised: the sandbox is still
|
|
175
|
+
# alive here, and partial artifacts beat none.
|
|
176
|
+
self._harvest(run_id, pair)
|
|
177
|
+
self._report_artifacts(run_id, pair)
|
|
178
|
+
except SdxloopError:
|
|
179
|
+
# Infra failures (install, worker, sbx) are exactly what
|
|
180
|
+
# gets diagnosed in-sandbox; decide keep before pair exit.
|
|
181
|
+
self._keep_on_failure(run_id, pair)
|
|
182
|
+
raise
|
|
157
183
|
if state == "completed":
|
|
158
184
|
self._deliver(run_id, outcome, pair, github)
|
|
185
|
+
else:
|
|
186
|
+
self._keep_on_failure(run_id, pair)
|
|
159
187
|
except SdxloopError:
|
|
160
188
|
# State is already persisted; the exception is the kill signal.
|
|
161
189
|
raise
|
|
@@ -168,6 +196,30 @@ class LoopEngine:
|
|
|
168
196
|
tasks=tasks,
|
|
169
197
|
workspace=pair.workspace,
|
|
170
198
|
mounted=pair.mounted,
|
|
199
|
+
kept_sandboxes=self._pair_names(pair) if pair.keep else [],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _pair_names(pair: SandboxPair) -> list[str]:
|
|
204
|
+
return [s.name for s in (pair.agent, pair.github) if s is not None]
|
|
205
|
+
|
|
206
|
+
def _keep_on_failure(self, run_id: str, pair: SandboxPair) -> None:
|
|
207
|
+
"""Flip the pair to kept when configured, so a failed run's evidence
|
|
208
|
+
survives for `sbxloop shell`. Marked in the DB for `sandbox prune`."""
|
|
209
|
+
if not self.config.keep_on_failure or pair.keep:
|
|
210
|
+
return
|
|
211
|
+
pair.keep = True
|
|
212
|
+
self.store.set_run_kept(run_id, "debug")
|
|
213
|
+
names = self._pair_names(pair)
|
|
214
|
+
self.bus.emit(
|
|
215
|
+
HostEventTypes.RUN_KEEP,
|
|
216
|
+
run_id,
|
|
217
|
+
sandboxes=names,
|
|
218
|
+
reason="debug",
|
|
219
|
+
message=(
|
|
220
|
+
f"sandboxes kept for debugging: {', '.join(names)} — "
|
|
221
|
+
f"inspect with `sbxloop shell {run_id}`"
|
|
222
|
+
),
|
|
171
223
|
)
|
|
172
224
|
|
|
173
225
|
def _attach_reporter(
|
|
@@ -214,12 +266,28 @@ class LoopEngine:
|
|
|
214
266
|
if target is None or not target.is_dir():
|
|
215
267
|
return
|
|
216
268
|
count = sum(1 for p in target.rglob("*") if p.is_file())
|
|
269
|
+
extra: dict[str, Any] = {}
|
|
270
|
+
sample = self._last_resources.get("agent")
|
|
271
|
+
if sample and sample.get("level") in ("warn", "abort"):
|
|
272
|
+
# Disk was under pressure at the last sample — harvested
|
|
273
|
+
# artifacts may be truncated or missing.
|
|
274
|
+
extra = {
|
|
275
|
+
"disk_used_pct": sample.get("disk_used_pct"),
|
|
276
|
+
"resources_level": sample.get("level"),
|
|
277
|
+
}
|
|
278
|
+
logger.warning(
|
|
279
|
+
"run %s: sandbox disk was at %s%% at the last sample — "
|
|
280
|
+
"harvested artifacts may be incomplete",
|
|
281
|
+
run_id,
|
|
282
|
+
sample.get("disk_used_pct"),
|
|
283
|
+
)
|
|
217
284
|
self.bus.emit(
|
|
218
285
|
HostEventTypes.RUN_ARTIFACTS,
|
|
219
286
|
run_id,
|
|
220
287
|
path=str(target),
|
|
221
288
|
files=count,
|
|
222
289
|
mounted=pair.mounted,
|
|
290
|
+
**extra,
|
|
223
291
|
)
|
|
224
292
|
|
|
225
293
|
def _deliver(
|
|
@@ -339,6 +407,14 @@ class LoopEngine:
|
|
|
339
407
|
|
|
340
408
|
while not task.terminal:
|
|
341
409
|
self._check_cancelled_and_clock(run_id, deadline)
|
|
410
|
+
abort_reason = self._resource_abort_reason()
|
|
411
|
+
if abort_reason:
|
|
412
|
+
# Fail the task with a diagnosis instead of letting the next
|
|
413
|
+
# phase produce garbage in a full sandbox. Dependents are
|
|
414
|
+
# skipped by the normal failed-task machinery.
|
|
415
|
+
task.last_feedback = abort_reason
|
|
416
|
+
self._set_task_state(run_id, task, "failed")
|
|
417
|
+
break
|
|
342
418
|
if task.state == "planning":
|
|
343
419
|
self._phase_plan(run_id, phases, task)
|
|
344
420
|
elif task.state == "executing":
|
|
@@ -475,6 +551,18 @@ class LoopEngine:
|
|
|
475
551
|
|
|
476
552
|
# -- bookkeeping -------------------------------------------------------
|
|
477
553
|
|
|
554
|
+
def _resource_abort_reason(self) -> str | None:
|
|
555
|
+
"""Non-None when the agent sandbox's last resource sample crossed
|
|
556
|
+
the disk_abort threshold (the worker classifies; the level rides on
|
|
557
|
+
the event)."""
|
|
558
|
+
sample = self._last_resources.get("agent")
|
|
559
|
+
if sample and sample.get("level") == "abort":
|
|
560
|
+
return (
|
|
561
|
+
f"sandbox disk exhausted: {sample.get('disk_used_pct')}% of the workspace "
|
|
562
|
+
f"filesystem is used (limits.disk_abort={self.config.limits.disk_abort}%)"
|
|
563
|
+
)
|
|
564
|
+
return None
|
|
565
|
+
|
|
478
566
|
def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
|
|
479
567
|
if self.store.get_run(run_id).state == "cancelled":
|
|
480
568
|
raise StateError(f"run {run_id} was cancelled")
|
|
@@ -156,6 +156,9 @@ class RunResult(_Model):
|
|
|
156
156
|
tasks: list[TaskRecord] = Field(default_factory=list)
|
|
157
157
|
workspace: Path | None = None
|
|
158
158
|
mounted: bool = False
|
|
159
|
+
# Sandbox names deliberately left alive (keep_sandboxes/keep_on_failure),
|
|
160
|
+
# so callers can point the user at `sbxloop shell`.
|
|
161
|
+
kept_sandboxes: list[str] = Field(default_factory=list)
|
|
159
162
|
|
|
160
163
|
@property
|
|
161
164
|
def succeeded(self) -> bool:
|
|
@@ -155,6 +155,22 @@ class SbxCLI:
|
|
|
155
155
|
raise self._error_for(result)
|
|
156
156
|
return result
|
|
157
157
|
|
|
158
|
+
def exec_interactive(self, name: str, cmd: Sequence[str]) -> int:
|
|
159
|
+
"""Run a command in a sandbox with the caller's terminal attached
|
|
160
|
+
(stdin/stdout/stderr inherited); returns the command's exit code.
|
|
161
|
+
|
|
162
|
+
`sbx exec` documents no -it-style flags; terminal attachment is
|
|
163
|
+
simply inheriting the caller's stdio.
|
|
164
|
+
"""
|
|
165
|
+
argv = self.argv("exec", name, *cmd)
|
|
166
|
+
try:
|
|
167
|
+
return subprocess.run(argv, check=False).returncode
|
|
168
|
+
except FileNotFoundError as exc:
|
|
169
|
+
raise SbxNotFoundError(
|
|
170
|
+
f"sbx binary {self.binary!r} not found on PATH",
|
|
171
|
+
argv=argv,
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
158
174
|
def cp(self, src: str, dst: str, *, timeout: float | None = None) -> None:
|
|
159
175
|
self.run("cp", src, dst, timeout=timeout)
|
|
160
176
|
|
|
@@ -27,7 +27,7 @@ from collections import deque
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
29
|
import sbxloop
|
|
30
|
-
from sbxloop.config import WorkerTransport
|
|
30
|
+
from sbxloop.config import Limits, WorkerTransport
|
|
31
31
|
from sbxloop.errors import SbxError, WorkerError, WorkerTimeoutError
|
|
32
32
|
from sbxloop.events import EventBus
|
|
33
33
|
from sbxloop.sbx.models import ExecResult
|
|
@@ -61,6 +61,8 @@ class WorkerClient:
|
|
|
61
61
|
python: str = DEFAULT_PYTHON,
|
|
62
62
|
poll_interval: float = 2.0,
|
|
63
63
|
grace_s: float = 60.0,
|
|
64
|
+
role: str | None = None,
|
|
65
|
+
limits: Limits | None = None,
|
|
64
66
|
) -> None:
|
|
65
67
|
self.sandbox = sandbox
|
|
66
68
|
self.bus = bus or EventBus()
|
|
@@ -68,6 +70,11 @@ class WorkerClient:
|
|
|
68
70
|
self.python = python
|
|
69
71
|
self.poll_interval = poll_interval
|
|
70
72
|
self.grace_s = grace_s
|
|
73
|
+
# Sandbox role for enriching resource telemetry (the worker doesn't
|
|
74
|
+
# know which sandbox it lives in), and guardrail thresholds to pass
|
|
75
|
+
# through to the worker's heartbeat sampler.
|
|
76
|
+
self.role = role
|
|
77
|
+
self.limits = limits
|
|
71
78
|
|
|
72
79
|
# -- install -----------------------------------------------------------
|
|
73
80
|
|
|
@@ -266,6 +273,15 @@ class WorkerClient:
|
|
|
266
273
|
# process itself chdirs there — agent SDK sessions inherit it.
|
|
267
274
|
if job.cwd:
|
|
268
275
|
argv += ["--cwd", job.cwd]
|
|
276
|
+
if self.limits is not None:
|
|
277
|
+
argv += [
|
|
278
|
+
"--disk-warn",
|
|
279
|
+
str(self.limits.disk_warn),
|
|
280
|
+
"--disk-abort",
|
|
281
|
+
str(self.limits.disk_abort),
|
|
282
|
+
"--mem-warn",
|
|
283
|
+
str(self.limits.mem_warn),
|
|
284
|
+
]
|
|
269
285
|
# sbx injects secrets through the sandbox session/profile machinery;
|
|
270
286
|
# a bare exec'd process may not see them. Run the worker under a
|
|
271
287
|
# login shell so the sandbox environment is fully loaded.
|
|
@@ -338,6 +354,11 @@ class WorkerClient:
|
|
|
338
354
|
Event.now(EventTypes.WORKER_STDOUT, job.run_id, job_id=job.job_id, line=line)
|
|
339
355
|
)
|
|
340
356
|
return
|
|
357
|
+
if self.role is not None and event.type in (
|
|
358
|
+
EventTypes.SANDBOX_RESOURCES,
|
|
359
|
+
EventTypes.SANDBOX_RESOURCES_WARNING,
|
|
360
|
+
):
|
|
361
|
+
event.data.setdefault("role", self.role)
|
|
341
362
|
self.bus.publish(event)
|
|
342
363
|
|
|
343
364
|
# -- poll transport ----------------------------------------------------
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|