sbxloop 0.5.5__tar.gz → 0.5.6__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 (43) hide show
  1. {sbxloop-0.5.5 → sbxloop-0.5.6}/PKG-INFO +1 -1
  2. sbxloop-0.5.5/src/sbxloop/_vendor/sbxloop_worker-0.5.5-py3-none-any.whl → sbxloop-0.5.6/src/sbxloop/_vendor/sbxloop_worker-0.5.6-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/cli/app.py +69 -1
  5. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/config.py +5 -0
  6. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/engine.py +69 -34
  7. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/model.py +3 -0
  8. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/events.py +1 -0
  9. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/cli.py +16 -0
  10. {sbxloop-0.5.5 → sbxloop-0.5.6}/.gitignore +0 -0
  11. {sbxloop-0.5.5 → sbxloop-0.5.6}/README.md +0 -0
  12. {sbxloop-0.5.5 → sbxloop-0.5.6}/hatch_build.py +0 -0
  13. {sbxloop-0.5.5 → sbxloop-0.5.6}/pyproject.toml +0 -0
  14. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/__init__.py +0 -0
  15. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/cli/__init__.py +0 -0
  16. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/cli/doctor.py +0 -0
  17. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/cli/tui.py +0 -0
  18. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/deliver.py +0 -0
  19. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/__init__.py +0 -0
  20. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/phases.py +0 -0
  21. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/__init__.py +0 -0
  22. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/decompose.md +0 -0
  23. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/execute.md +0 -0
  24. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/plan.md +0 -0
  25. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  26. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/prompts/validate.md +0 -0
  27. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/engine/store.py +0 -0
  28. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/errors.py +0 -0
  29. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/gh/__init__.py +0 -0
  30. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/gh/ops.py +0 -0
  31. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/gh/reporter.py +0 -0
  32. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/ids.py +0 -0
  33. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/py.typed +0 -0
  34. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/__init__.py +0 -0
  35. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/models.py +0 -0
  36. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/pair.py +0 -0
  37. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/parse.py +0 -0
  38. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/provision.py +0 -0
  39. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/prune.py +0 -0
  40. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/sbx/sandbox.py +0 -0
  41. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/worker/__init__.py +0 -0
  42. {sbxloop-0.5.5 → sbxloop-0.5.6}/src/sbxloop/worker/client.py +0 -0
  43. {sbxloop-0.5.5 → sbxloop-0.5.6}/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.6
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.6'
22
+ __version_tuple__ = version_tuple = (0, 5, 6)
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".
@@ -85,6 +85,11 @@ class Config(_ConfigModel):
85
85
  app_name: str = ""
86
86
  state_dir: Path = Path(".sbxloop")
87
87
  keep_sandboxes: bool = False
88
+ # Keep the pair alive only when a run fails, so the evidence (worker
89
+ # stderr, install leftovers, workspace state) survives for
90
+ # `sbxloop shell <run>`. Kept runs are marked in the state DB and
91
+ # eventually collectable via `sbxloop sandbox prune --include-kept`.
92
+ keep_on_failure: bool = False
88
93
  worker_transport: WorkerTransport = "stream"
89
94
  secret_strategy: SecretStrategy = "proxy"
90
95
  # Advanced: in-sandbox interpreter for the worker, and whether to run the
@@ -113,49 +113,60 @@ class LoopEngine:
113
113
  pair = provisioner.ensure_pair(run_id)
114
114
  assert pair.workspace is not None
115
115
  self.store.set_run_workspace(run_id, pair.workspace, pair.mounted)
116
+ if pair.keep:
117
+ # keep_sandboxes: mark up front so `sandbox prune` respects it.
118
+ self.store.set_run_kept(run_id, "manual")
116
119
  try:
117
120
  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,
121
+ try:
122
+ agent = WorkerClient(
123
+ pair.agent,
127
124
  self.bus,
128
125
  transport=self.config.worker_transport,
129
126
  python=self.worker_python,
130
127
  )
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
128
+ github = (
129
+ WorkerClient(
130
+ pair.github,
131
+ self.bus,
132
+ transport=self.config.worker_transport,
133
+ python=self.worker_python,
134
+ )
135
+ if pair.github is not None
136
+ else None
144
137
  )
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)
138
+ if self.install_workers:
139
+ # ensure_dev_tools: the agent builds projects in this VM
140
+ # (venvs, pip) the github sandbox only runs API ops.
141
+ agent.install(extras="copilot", ensure_dev_tools=True)
142
+ if github is not None:
143
+ github.install(extras="")
144
+ reporter, detach = self._attach_reporter(github, run_id, outcome)
145
+ try:
146
+ phases = PhaseRunner(
147
+ agent, self.config, run_id, outcome, workdir=pair.agent_workdir
148
+ )
149
+ state = self._run_phases(run_id, phases, deadline, pair)
150
+ # Summary must post while the github sandbox is alive;
151
+ # on an infra exception the run is resumable and the
152
+ # resumed run reopens the same tracking issue.
153
+ if reporter is not None:
154
+ reporter.close_run(run_id, state)
155
+ finally:
156
+ detach()
157
+ # Harvest even when a phase raised: the sandbox is still
158
+ # alive here, and partial artifacts beat none.
159
+ self._harvest(run_id, pair)
160
+ self._report_artifacts(run_id, pair)
161
+ except SdxloopError:
162
+ # Infra failures (install, worker, sbx) are exactly what
163
+ # gets diagnosed in-sandbox; decide keep before pair exit.
164
+ self._keep_on_failure(run_id, pair)
165
+ raise
157
166
  if state == "completed":
158
167
  self._deliver(run_id, outcome, pair, github)
168
+ else:
169
+ self._keep_on_failure(run_id, pair)
159
170
  except SdxloopError:
160
171
  # State is already persisted; the exception is the kill signal.
161
172
  raise
@@ -168,6 +179,30 @@ class LoopEngine:
168
179
  tasks=tasks,
169
180
  workspace=pair.workspace,
170
181
  mounted=pair.mounted,
182
+ kept_sandboxes=self._pair_names(pair) if pair.keep else [],
183
+ )
184
+
185
+ @staticmethod
186
+ def _pair_names(pair: SandboxPair) -> list[str]:
187
+ return [s.name for s in (pair.agent, pair.github) if s is not None]
188
+
189
+ def _keep_on_failure(self, run_id: str, pair: SandboxPair) -> None:
190
+ """Flip the pair to kept when configured, so a failed run's evidence
191
+ survives for `sbxloop shell`. Marked in the DB for `sandbox prune`."""
192
+ if not self.config.keep_on_failure or pair.keep:
193
+ return
194
+ pair.keep = True
195
+ self.store.set_run_kept(run_id, "debug")
196
+ names = self._pair_names(pair)
197
+ self.bus.emit(
198
+ HostEventTypes.RUN_KEEP,
199
+ run_id,
200
+ sandboxes=names,
201
+ reason="debug",
202
+ message=(
203
+ f"sandboxes kept for debugging: {', '.join(names)} — "
204
+ f"inspect with `sbxloop shell {run_id}`"
205
+ ),
171
206
  )
172
207
 
173
208
  def _attach_reporter(
@@ -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
 
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