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.
Files changed (44) hide show
  1. {sbxloop-0.5.5 → sbxloop-0.5.7}/PKG-INFO +1 -1
  2. sbxloop-0.5.7/src/sbxloop/_vendor/sbxloop_worker-0.5.7-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/app.py +78 -1
  5. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/doctor.py +23 -0
  6. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/tui.py +42 -4
  7. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/config.py +43 -1
  8. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/engine.py +122 -34
  9. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/model.py +3 -0
  10. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/events.py +1 -0
  11. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/cli.py +16 -0
  12. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/worker/client.py +22 -1
  13. sbxloop-0.5.5/src/sbxloop/_vendor/sbxloop_worker-0.5.5-py3-none-any.whl +0 -0
  14. {sbxloop-0.5.5 → sbxloop-0.5.7}/.gitignore +0 -0
  15. {sbxloop-0.5.5 → sbxloop-0.5.7}/README.md +0 -0
  16. {sbxloop-0.5.5 → sbxloop-0.5.7}/hatch_build.py +0 -0
  17. {sbxloop-0.5.5 → sbxloop-0.5.7}/pyproject.toml +0 -0
  18. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/__init__.py +0 -0
  19. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/cli/__init__.py +0 -0
  20. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/deliver.py +0 -0
  21. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/__init__.py +0 -0
  22. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/phases.py +0 -0
  23. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/__init__.py +0 -0
  24. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/decompose.md +0 -0
  25. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/execute.md +0 -0
  26. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/plan.md +0 -0
  27. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  28. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/validate.md +0 -0
  29. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/engine/store.py +0 -0
  30. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/errors.py +0 -0
  31. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/__init__.py +0 -0
  32. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/ops.py +0 -0
  33. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/gh/reporter.py +0 -0
  34. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/ids.py +0 -0
  35. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/py.typed +0 -0
  36. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/__init__.py +0 -0
  37. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/models.py +0 -0
  38. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/pair.py +0 -0
  39. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/parse.py +0 -0
  40. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/provision.py +0 -0
  41. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/prune.py +0 -0
  42. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/sbx/sandbox.py +0 -0
  43. {sbxloop-0.5.5 → sbxloop-0.5.7}/src/sbxloop/worker/__init__.py +0 -0
  44. {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.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
@@ -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.5'
22
- __version_tuple__ = version_tuple = (0, 5, 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(model=model, keep_sandboxes=keep_sandboxes or None)
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 == HostEventTypes.RUN_START:
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 BaseModel, ConfigDict, Field, ValidationError, field_validator
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
- agent = WorkerClient(
119
- pair.agent,
120
- self.bus,
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
- if pair.github is not None
132
- else None
133
- )
134
- if self.install_workers:
135
- # ensure_dev_tools: the agent builds projects in this VM
136
- # (venvs, pip) — the github sandbox only runs API ops.
137
- agent.install(extras="copilot", ensure_dev_tools=True)
138
- if github is not None:
139
- github.install(extras="")
140
- reporter, detach = self._attach_reporter(github, run_id, outcome)
141
- try:
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
- state = self._run_phases(run_id, phases, deadline, pair)
146
- # Summary must post while the github sandbox is alive;
147
- # on an infra exception the run is resumable and the
148
- # resumed run reopens the same tracking issue.
149
- if reporter is not None:
150
- reporter.close_run(run_id, state)
151
- finally:
152
- detach()
153
- # Harvest even when a phase raised: the sandbox is still
154
- # alive here, and partial artifacts beat none.
155
- self._harvest(run_id, pair)
156
- self._report_artifacts(run_id, pair)
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:
@@ -75,6 +75,7 @@ class HostEventTypes:
75
75
  RUN_STATE = "run.state"
76
76
  RUN_ARTIFACTS = "run.artifacts"
77
77
  RUN_DELIVER = "run.deliver"
78
+ RUN_KEEP = "run.keep"
78
79
  RUN_END = "run.end"
79
80
  TASK_START = "task.start"
80
81
  TASK_STATE = "task.state"
@@ -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 ----------------------------------------------------
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