sbxloop 0.2.0__py3-none-any.whl
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/__init__.py +26 -0
- sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- sbxloop/cli/__init__.py +1 -0
- sbxloop/cli/app.py +365 -0
- sbxloop/cli/doctor.py +172 -0
- sbxloop/cli/tui.py +206 -0
- sbxloop/config.py +205 -0
- sbxloop/engine/__init__.py +26 -0
- sbxloop/engine/engine.py +383 -0
- sbxloop/engine/model.py +151 -0
- sbxloop/engine/phases.py +223 -0
- sbxloop/engine/prompts/__init__.py +31 -0
- sbxloop/engine/prompts/decompose.md +41 -0
- sbxloop/engine/prompts/execute.md +31 -0
- sbxloop/engine/prompts/plan.md +35 -0
- sbxloop/engine/prompts/scrutinize.md +43 -0
- sbxloop/engine/prompts/validate.md +38 -0
- sbxloop/engine/store.py +257 -0
- sbxloop/errors.py +73 -0
- sbxloop/events.py +87 -0
- sbxloop/gh/__init__.py +6 -0
- sbxloop/gh/ops.py +140 -0
- sbxloop/gh/reporter.py +75 -0
- sbxloop/ids.py +35 -0
- sbxloop/py.typed +0 -0
- sbxloop/sbx/__init__.py +6 -0
- sbxloop/sbx/cli.py +236 -0
- sbxloop/sbx/models.py +83 -0
- sbxloop/sbx/pair.py +125 -0
- sbxloop/sbx/parse.py +67 -0
- sbxloop/sbx/provision.py +361 -0
- sbxloop/sbx/sandbox.py +75 -0
- sbxloop/worker/__init__.py +6 -0
- sbxloop/worker/client.py +397 -0
- sbxloop/worker/wheel.py +73 -0
- sbxloop-0.2.0.dist-info/METADATA +33 -0
- sbxloop-0.2.0.dist-info/RECORD +39 -0
- sbxloop-0.2.0.dist-info/WHEEL +4 -0
- sbxloop-0.2.0.dist-info/entry_points.txt +2 -0
sbxloop/worker/client.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""WorkerClient: install the worker into a sandbox and run jobs through it.
|
|
2
|
+
|
|
3
|
+
Two transports:
|
|
4
|
+
|
|
5
|
+
- **stream** (default): one blocking ``sbx exec`` per job; the worker mirrors
|
|
6
|
+
its JSONL events to stdout, which the host parses line-by-line and
|
|
7
|
+
republishes on the EventBus. The result file is fetched afterwards with
|
|
8
|
+
``cp`` — stdout is telemetry, the result file is the outcome.
|
|
9
|
+
- **poll**: the worker is launched detached (``nohup ... &``); the host tails
|
|
10
|
+
the in-sandbox events file by byte offset every ``poll_interval`` seconds.
|
|
11
|
+
Fallback for environments where long-running exec streams are unreliable.
|
|
12
|
+
|
|
13
|
+
Host-side timeouts are ``job.timeout_s`` plus a grace period; on expiry the
|
|
14
|
+
worker process is killed inside the sandbox (pattern-scoped pkill) and
|
|
15
|
+
WorkerTimeoutError is raised.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import queue
|
|
23
|
+
import shlex
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections import deque
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import sbxloop
|
|
30
|
+
from sbxloop.config import WorkerTransport
|
|
31
|
+
from sbxloop.errors import SbxError, WorkerError, WorkerTimeoutError
|
|
32
|
+
from sbxloop.events import EventBus
|
|
33
|
+
from sbxloop.sbx.models import ExecResult
|
|
34
|
+
from sbxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, VENV_DIR, Sandbox
|
|
35
|
+
from sbxloop.sbx.sandbox import VENV_PYTHON as DEFAULT_PYTHON
|
|
36
|
+
from sbxloop.worker.wheel import resolve_worker_wheel
|
|
37
|
+
from sbxloop_worker.protocol import Event, EventTypes, JobRequest, JobResult
|
|
38
|
+
|
|
39
|
+
# Wheels must keep their canonical filename when staged: pip validates the
|
|
40
|
+
# name-version-python-abi-platform structure of the FILENAME itself and
|
|
41
|
+
# refuses to install a renamed wheel ("Invalid wheel filename").
|
|
42
|
+
STAGED_WHEEL_DIR = "/tmp"
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _output_tail(result: ExecResult, limit: int = 2000) -> str:
|
|
48
|
+
"""Combined stderr+stdout tail: sbx exec surfaces some in-sandbox errors
|
|
49
|
+
on stdout, so stderr alone can be empty exactly when it matters."""
|
|
50
|
+
combined = "\n".join(part.strip() for part in (result.stderr, result.stdout) if part.strip())
|
|
51
|
+
return combined[-limit:] if combined else "(no output)"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WorkerClient:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
sandbox: Sandbox,
|
|
58
|
+
bus: EventBus | None = None,
|
|
59
|
+
*,
|
|
60
|
+
transport: WorkerTransport = "stream",
|
|
61
|
+
python: str = DEFAULT_PYTHON,
|
|
62
|
+
poll_interval: float = 2.0,
|
|
63
|
+
grace_s: float = 60.0,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.sandbox = sandbox
|
|
66
|
+
self.bus = bus or EventBus()
|
|
67
|
+
self.transport = transport
|
|
68
|
+
self.python = python
|
|
69
|
+
self.poll_interval = poll_interval
|
|
70
|
+
self.grace_s = grace_s
|
|
71
|
+
|
|
72
|
+
# -- install -----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def install(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
extras: str = "copilot",
|
|
78
|
+
wheel: Path | None = None,
|
|
79
|
+
timeout: float = 600.0,
|
|
80
|
+
no_deps: bool = False,
|
|
81
|
+
system_site_packages: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Install sbxloop-worker into the sandbox, venv-first with fallbacks.
|
|
84
|
+
|
|
85
|
+
Sandbox templates ship python3 but often lack python3-venv
|
|
86
|
+
(Debian/Ubuntu split ensurepip out). The ladder:
|
|
87
|
+
|
|
88
|
+
1. ``python3 -m venv`` — the clean path.
|
|
89
|
+
2. On a venv/ensurepip failure: ``sudo -n apt-get install
|
|
90
|
+
python3-venv python3-pip`` (the template's agent user has sudo;
|
|
91
|
+
apt hosts are on the balanced allowlist), then retry the venv.
|
|
92
|
+
3. Still no venv: **user-site fallback** — ``python3 -m pip install
|
|
93
|
+
--user`` (adding ``--break-system-packages`` when pip reports an
|
|
94
|
+
externally-managed environment), and the worker runs under the
|
|
95
|
+
system ``python3``. ``self.python`` is updated so submit() uses
|
|
96
|
+
the right interpreter either way.
|
|
97
|
+
|
|
98
|
+
``no_deps``/``system_site_packages`` are test seams for hermetic
|
|
99
|
+
installs; production uses full dependency resolution (PyPI is
|
|
100
|
+
reachable under the balanced network policy).
|
|
101
|
+
"""
|
|
102
|
+
wheel = wheel if wheel is not None else resolve_worker_wheel()
|
|
103
|
+
if wheel is not None:
|
|
104
|
+
staged = f"{STAGED_WHEEL_DIR}/{wheel.name}"
|
|
105
|
+
self.sandbox.cp_in(wheel, staged)
|
|
106
|
+
base_target = staged
|
|
107
|
+
else:
|
|
108
|
+
base_target = f"sbxloop-worker=={sbxloop.__version__}"
|
|
109
|
+
target = f"{base_target}[{extras}]" if extras else base_target
|
|
110
|
+
|
|
111
|
+
if self._create_venv(timeout, system_site_packages):
|
|
112
|
+
self.python = DEFAULT_PYTHON
|
|
113
|
+
pip = [f"{VENV_DIR}/bin/pip", "install", "--quiet"]
|
|
114
|
+
if no_deps:
|
|
115
|
+
pip.append("--no-deps")
|
|
116
|
+
self._check(self.sandbox.exec([*pip, target], timeout=timeout), "worker install")
|
|
117
|
+
else:
|
|
118
|
+
self.python = "python3"
|
|
119
|
+
self._pip_user_install(target, timeout=timeout, no_deps=no_deps)
|
|
120
|
+
|
|
121
|
+
verify = self.sandbox.exec(
|
|
122
|
+
[self.python, "-c", "import sbxloop_worker; print(sbxloop_worker.__version__)"]
|
|
123
|
+
)
|
|
124
|
+
self._check(verify, "worker import check")
|
|
125
|
+
installed = verify.stdout.strip()
|
|
126
|
+
if installed != sbxloop.__version__:
|
|
127
|
+
raise WorkerError(
|
|
128
|
+
f"worker version {installed!r} does not match host {sbxloop.__version__!r}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Entrypoint smoke check: importing the package proves nothing about
|
|
132
|
+
# `python -m sbxloop_worker` actually executing under sbx exec. A run
|
|
133
|
+
# against a missing job file must exit 64 (the worker's usage-error
|
|
134
|
+
# code) — anything else means jobs would die with no result file,
|
|
135
|
+
# so fail HERE with full output instead of at the first real job.
|
|
136
|
+
smoke = self.sandbox.exec(
|
|
137
|
+
[
|
|
138
|
+
self.python,
|
|
139
|
+
"-m",
|
|
140
|
+
"sbxloop_worker",
|
|
141
|
+
"run",
|
|
142
|
+
"--job",
|
|
143
|
+
"/tmp/sbxloop-smoke-missing.json",
|
|
144
|
+
"--events",
|
|
145
|
+
"/tmp/sbxloop-smoke.events.jsonl",
|
|
146
|
+
"--result",
|
|
147
|
+
"/tmp/sbxloop-smoke.result.json",
|
|
148
|
+
]
|
|
149
|
+
)
|
|
150
|
+
if smoke.returncode != 64:
|
|
151
|
+
raise WorkerError(
|
|
152
|
+
"worker entrypoint check failed "
|
|
153
|
+
f"(rc={smoke.returncode}, expected 64): {_output_tail(smoke)}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _create_venv(self, timeout: float, system_site_packages: bool) -> bool:
|
|
157
|
+
venv_cmd = ["python3", "-m", "venv"]
|
|
158
|
+
if system_site_packages:
|
|
159
|
+
venv_cmd.append("--system-site-packages")
|
|
160
|
+
venv_cmd.append(VENV_DIR)
|
|
161
|
+
|
|
162
|
+
result = self.sandbox.exec(venv_cmd, timeout=timeout)
|
|
163
|
+
if result.ok:
|
|
164
|
+
return True
|
|
165
|
+
output = f"{result.stdout} {result.stderr}".lower()
|
|
166
|
+
if "ensurepip" in output or "venv" in output:
|
|
167
|
+
# Self-heal: the official templates run Ubuntu with a sudo-capable
|
|
168
|
+
# agent user, and apt hosts are on the balanced allowlist.
|
|
169
|
+
self.sandbox.exec(
|
|
170
|
+
[
|
|
171
|
+
"sh",
|
|
172
|
+
"-c",
|
|
173
|
+
"sudo -n apt-get update -q && "
|
|
174
|
+
"sudo -n apt-get install -y -q python3-venv python3-pip",
|
|
175
|
+
],
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
)
|
|
178
|
+
result = self.sandbox.exec(venv_cmd, timeout=timeout)
|
|
179
|
+
if result.ok:
|
|
180
|
+
return True
|
|
181
|
+
logger.warning(
|
|
182
|
+
"venv creation failed (rc=%s): %s — falling back to a user-site install "
|
|
183
|
+
"with the system python3",
|
|
184
|
+
result.returncode,
|
|
185
|
+
_output_tail(result),
|
|
186
|
+
)
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
def _pip_user_install(self, target: str, *, timeout: float, no_deps: bool) -> None:
|
|
190
|
+
pip = ["python3", "-m", "pip", "install", "--quiet", "--user"]
|
|
191
|
+
if no_deps:
|
|
192
|
+
pip.append("--no-deps")
|
|
193
|
+
result = self.sandbox.exec([*pip, target], timeout=timeout)
|
|
194
|
+
if not result.ok and "externally-managed" in f"{result.stdout} {result.stderr}".lower():
|
|
195
|
+
# PEP 668 (Ubuntu 24.04+): system pip refuses --user without an
|
|
196
|
+
# explicit opt-out.
|
|
197
|
+
result = self.sandbox.exec([*pip, "--break-system-packages", target], timeout=timeout)
|
|
198
|
+
self._check(result, "worker install (user-site fallback)")
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _check(result: ExecResult, step: str) -> None:
|
|
202
|
+
if not result.ok:
|
|
203
|
+
raise WorkerError(f"{step} failed (rc={result.returncode}): {_output_tail(result)}")
|
|
204
|
+
|
|
205
|
+
# -- submit ------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def submit(self, job: JobRequest) -> JobResult:
|
|
208
|
+
job_path = f"{JOBS_DIR}/{job.job_id}.json"
|
|
209
|
+
events_path = f"{EVENTS_DIR}/{job.job_id}.jsonl"
|
|
210
|
+
result_path = f"{RESULTS_DIR}/{job.job_id}.json"
|
|
211
|
+
self.sandbox.write_text(job_path, job.model_dump_json())
|
|
212
|
+
|
|
213
|
+
argv = [
|
|
214
|
+
self.python,
|
|
215
|
+
"-m",
|
|
216
|
+
"sbxloop_worker",
|
|
217
|
+
"run",
|
|
218
|
+
"--job",
|
|
219
|
+
job_path,
|
|
220
|
+
"--events",
|
|
221
|
+
events_path,
|
|
222
|
+
"--result",
|
|
223
|
+
result_path,
|
|
224
|
+
"--env-file",
|
|
225
|
+
ENV_FILE,
|
|
226
|
+
]
|
|
227
|
+
# sbx injects secrets through the sandbox session/profile machinery;
|
|
228
|
+
# a bare exec'd process may not see them. Run the worker under a
|
|
229
|
+
# login shell so the sandbox environment is fully loaded.
|
|
230
|
+
wrapped = ["sh", "-lc", shlex.join(argv)]
|
|
231
|
+
deadline = time.monotonic() + job.timeout_s + self.grace_s
|
|
232
|
+
if self.transport == "poll":
|
|
233
|
+
self._run_poll(job, wrapped, events_path, result_path, deadline)
|
|
234
|
+
diagnostics = ""
|
|
235
|
+
else:
|
|
236
|
+
diagnostics = self._run_stream(job, wrapped, deadline)
|
|
237
|
+
return self._fetch_result(job, result_path, events_path, diagnostics)
|
|
238
|
+
|
|
239
|
+
# -- stream transport --------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def _run_stream(self, job: JobRequest, argv: list[str], deadline: float) -> str:
|
|
242
|
+
"""Run the worker via a blocking exec; returns diagnostics (exit code
|
|
243
|
+
+ stderr tail) for the no-result failure path."""
|
|
244
|
+
proc = self.sandbox.exec_stream(argv)
|
|
245
|
+
lines: queue.Queue[str | None] = queue.Queue()
|
|
246
|
+
stderr_tail: deque[str] = deque(maxlen=50)
|
|
247
|
+
|
|
248
|
+
def reader() -> None:
|
|
249
|
+
assert proc.stdout is not None
|
|
250
|
+
for line in proc.stdout:
|
|
251
|
+
lines.put(line)
|
|
252
|
+
lines.put(None)
|
|
253
|
+
|
|
254
|
+
def err_reader() -> None:
|
|
255
|
+
# stderr must be drained: an unread PIPE deadlocks a chatty
|
|
256
|
+
# worker once the 64KB buffer fills — and its content is the
|
|
257
|
+
# only clue when the process dies before writing a result.
|
|
258
|
+
assert proc.stderr is not None
|
|
259
|
+
for line in proc.stderr:
|
|
260
|
+
stderr_tail.append(line.rstrip())
|
|
261
|
+
|
|
262
|
+
threading.Thread(target=reader, name="sbxloop-stream-reader", daemon=True).start()
|
|
263
|
+
threading.Thread(target=err_reader, name="sbxloop-stderr-reader", daemon=True).start()
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
while True:
|
|
267
|
+
remaining = deadline - time.monotonic()
|
|
268
|
+
if remaining <= 0:
|
|
269
|
+
self._kill(job, proc)
|
|
270
|
+
raise WorkerTimeoutError(
|
|
271
|
+
f"job {job.job_id} exceeded {job.timeout_s}s (+{self.grace_s}s grace)"
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
line = lines.get(timeout=min(remaining, 0.5))
|
|
275
|
+
except queue.Empty:
|
|
276
|
+
continue
|
|
277
|
+
if line is None:
|
|
278
|
+
break
|
|
279
|
+
self._handle_line(job, line)
|
|
280
|
+
finally:
|
|
281
|
+
with contextlib.suppress(Exception):
|
|
282
|
+
proc.wait(timeout=self.grace_s)
|
|
283
|
+
parts = [f"exec rc={proc.returncode}"]
|
|
284
|
+
if stderr_tail:
|
|
285
|
+
parts.append("stderr: " + " | ".join(stderr_tail)[-1500:])
|
|
286
|
+
return "; ".join(parts)
|
|
287
|
+
|
|
288
|
+
def _handle_line(self, job: JobRequest, line: str) -> None:
|
|
289
|
+
line = line.strip()
|
|
290
|
+
if not line:
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
event = Event.from_json_line(line)
|
|
294
|
+
except ValueError:
|
|
295
|
+
self.bus.publish(
|
|
296
|
+
Event.now(EventTypes.WORKER_STDOUT, job.run_id, job_id=job.job_id, line=line)
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
self.bus.publish(event)
|
|
300
|
+
|
|
301
|
+
# -- poll transport ----------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def _run_poll(
|
|
304
|
+
self,
|
|
305
|
+
job: JobRequest,
|
|
306
|
+
argv: list[str],
|
|
307
|
+
events_path: str,
|
|
308
|
+
result_path: str,
|
|
309
|
+
deadline: float,
|
|
310
|
+
) -> None:
|
|
311
|
+
quoted = shlex.join(argv)
|
|
312
|
+
launch = self.sandbox.exec(["sh", "-c", f"nohup {quoted} >/dev/null 2>&1 & echo $!"])
|
|
313
|
+
if not launch.ok:
|
|
314
|
+
raise WorkerError(f"failed to launch worker: {launch.stderr.strip()[:2000]}")
|
|
315
|
+
pid = launch.stdout.strip().splitlines()[-1] if launch.stdout.strip() else ""
|
|
316
|
+
|
|
317
|
+
offset = 0
|
|
318
|
+
buffer = ""
|
|
319
|
+
|
|
320
|
+
def drain() -> bool:
|
|
321
|
+
"""Read new event bytes; return True once worker.end is seen."""
|
|
322
|
+
nonlocal offset, buffer
|
|
323
|
+
chunk = self.sandbox.exec(
|
|
324
|
+
["sh", "-c", f"tail -c +{offset + 1} {events_path} 2>/dev/null || true"]
|
|
325
|
+
)
|
|
326
|
+
finished = False
|
|
327
|
+
if chunk.stdout:
|
|
328
|
+
offset += len(chunk.stdout.encode())
|
|
329
|
+
*complete, buffer = (buffer + chunk.stdout).split("\n")
|
|
330
|
+
for line in complete:
|
|
331
|
+
self._handle_line(job, line)
|
|
332
|
+
if '"worker.end"' in line:
|
|
333
|
+
finished = True
|
|
334
|
+
return finished
|
|
335
|
+
|
|
336
|
+
while True:
|
|
337
|
+
if time.monotonic() > deadline:
|
|
338
|
+
self._kill(job, None)
|
|
339
|
+
raise WorkerTimeoutError(
|
|
340
|
+
f"job {job.job_id} exceeded {job.timeout_s}s (+{self.grace_s}s grace)"
|
|
341
|
+
)
|
|
342
|
+
time.sleep(self.poll_interval)
|
|
343
|
+
if drain():
|
|
344
|
+
break
|
|
345
|
+
if pid:
|
|
346
|
+
alive = self.sandbox.exec(
|
|
347
|
+
["sh", "-c", f"kill -0 {pid} 2>/dev/null && echo alive || echo dead"]
|
|
348
|
+
)
|
|
349
|
+
if "dead" in alive.stdout:
|
|
350
|
+
# Worker exited between polls: drain whatever remains.
|
|
351
|
+
drain()
|
|
352
|
+
break
|
|
353
|
+
|
|
354
|
+
# -- helpers -----------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def _kill(self, job: JobRequest, proc: object) -> None:
|
|
357
|
+
# Pattern is job-id scoped so concurrent workers are never collateral.
|
|
358
|
+
with contextlib.suppress(Exception):
|
|
359
|
+
self.sandbox.exec(["pkill", "-f", f"sbxloop_worker.*{job.job_id}"])
|
|
360
|
+
if proc is not None:
|
|
361
|
+
with contextlib.suppress(Exception):
|
|
362
|
+
proc.kill() # type: ignore[attr-defined]
|
|
363
|
+
|
|
364
|
+
def _events_tail(self, events_path: str, lines: int = 5) -> str:
|
|
365
|
+
if not events_path:
|
|
366
|
+
return ""
|
|
367
|
+
with contextlib.suppress(Exception):
|
|
368
|
+
result = self.sandbox.exec(
|
|
369
|
+
["sh", "-c", f"tail -n {lines} {events_path} 2>/dev/null || true"]
|
|
370
|
+
)
|
|
371
|
+
return result.stdout.strip().replace("\n", " | ")[-1500:]
|
|
372
|
+
return ""
|
|
373
|
+
|
|
374
|
+
def _fetch_result(
|
|
375
|
+
self,
|
|
376
|
+
job: JobRequest,
|
|
377
|
+
result_path: str,
|
|
378
|
+
events_path: str = "",
|
|
379
|
+
diagnostics: str = "",
|
|
380
|
+
) -> JobResult:
|
|
381
|
+
try:
|
|
382
|
+
raw = self.sandbox.read_text(result_path)
|
|
383
|
+
except SbxError as exc:
|
|
384
|
+
detail = [f"worker for job {job.job_id} produced no result file ({result_path})"]
|
|
385
|
+
if diagnostics:
|
|
386
|
+
detail.append(diagnostics)
|
|
387
|
+
tail = self._events_tail(events_path)
|
|
388
|
+
if tail:
|
|
389
|
+
detail.append(f"last events: {tail}")
|
|
390
|
+
raise WorkerError("; ".join(detail)) from exc
|
|
391
|
+
try:
|
|
392
|
+
result = JobResult.model_validate_json(raw)
|
|
393
|
+
except ValueError as exc:
|
|
394
|
+
raise WorkerError(f"invalid result file for job {job.job_id}: {exc}") from exc
|
|
395
|
+
if result.job_id != job.job_id:
|
|
396
|
+
raise WorkerError(f"result job_id mismatch: expected {job.job_id}, got {result.job_id}")
|
|
397
|
+
return result
|
sbxloop/worker/wheel.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Locate (or build) the sbxloop-worker wheel to install into sandboxes.
|
|
2
|
+
|
|
3
|
+
Resolution order:
|
|
4
|
+
|
|
5
|
+
1. **Vendored** — the wheel embedded in the installed sbxloop package at
|
|
6
|
+
``sbxloop/_vendor/`` (placed there by the hatch build hook). This is the
|
|
7
|
+
production path and works with zero network access to PyPI for sbxloop
|
|
8
|
+
itself.
|
|
9
|
+
2. **Workspace build** — when running from a source checkout (no vendor dir),
|
|
10
|
+
build the wheel from the sibling ``packages/sbxloop-worker`` tree with
|
|
11
|
+
``uv build``. Cached per process.
|
|
12
|
+
3. **None** — the caller falls back to installing ``sbxloop-worker`` from
|
|
13
|
+
PyPI inside the sandbox at the exact lockstep version.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from importlib import resources
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import sbxloop
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _vendored_wheel() -> Path | None:
|
|
32
|
+
expected = f"sbxloop_worker-{sbxloop.__version__}-py3-none-any.whl"
|
|
33
|
+
try:
|
|
34
|
+
vendor = resources.files("sbxloop") / "_vendor"
|
|
35
|
+
candidate = Path(str(vendor)) / expected
|
|
36
|
+
except (ModuleNotFoundError, TypeError): # pragma: no cover - defensive
|
|
37
|
+
return None
|
|
38
|
+
return candidate if candidate.is_file() else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _workspace_worker_root() -> Path | None:
|
|
42
|
+
# <repo>/packages/sbxloop/src/sbxloop/__init__.py -> <repo>/packages/sbxloop-worker
|
|
43
|
+
package_dir = Path(sbxloop.__file__).resolve().parent
|
|
44
|
+
candidate = package_dir.parent.parent.parent / "sbxloop-worker"
|
|
45
|
+
return candidate if (candidate / "pyproject.toml").is_file() else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def _workspace_build() -> Path | None:
|
|
50
|
+
worker_root = _workspace_worker_root()
|
|
51
|
+
if worker_root is None or shutil.which("uv") is None:
|
|
52
|
+
return None
|
|
53
|
+
out_dir = Path(tempfile.mkdtemp(prefix="sbxloop-worker-wheel-"))
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["uv", "build", "--wheel", "-o", str(out_dir), str(worker_root)],
|
|
57
|
+
check=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
timeout=300,
|
|
60
|
+
)
|
|
61
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
62
|
+
logger.warning("workspace worker wheel build failed: %s", exc)
|
|
63
|
+
return None
|
|
64
|
+
wheels = sorted(out_dir.glob("sbxloop_worker-*.whl"))
|
|
65
|
+
return wheels[-1] if wheels else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_worker_wheel() -> Path | None:
|
|
69
|
+
"""Best available worker wheel on this host, or None to use PyPI."""
|
|
70
|
+
wheel = _vendored_wheel()
|
|
71
|
+
if wheel is not None:
|
|
72
|
+
return wheel
|
|
73
|
+
return _workspace_build()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sbxloop
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
|
|
5
|
+
Project-URL: Homepage, https://github.com/brettbergin/sbxloop
|
|
6
|
+
Project-URL: Repository, https://github.com/brettbergin/sbxloop
|
|
7
|
+
Project-URL: Issues, https://github.com/brettbergin/sbxloop/issues
|
|
8
|
+
Author: Brett Bergin
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,copilot,docker,orchestration,sandbox
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Requires-Dist: sbxloop-worker==0.2.0
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# sbxloop
|
|
27
|
+
|
|
28
|
+
Agentic loop orchestration on Docker Sandboxes (`sbx`) with isolated credential
|
|
29
|
+
domains. This is the host-side orchestrator package: sandbox provisioning, the
|
|
30
|
+
loop engine, and the `sbxloop` CLI.
|
|
31
|
+
|
|
32
|
+
See the [project README](https://github.com/brettbergin/sbxloop) for full
|
|
33
|
+
documentation.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
sbxloop/__init__.py,sha256=RQcCoppqAISnZXZNTm9VXGKa5xtz38vXNHRW4rSUs9Y,741
|
|
2
|
+
sbxloop/config.py,sha256=VjjmQ-mjRBYw8AHHmo6D2-InqS5A5OO4tCoCy_ACim0,6757
|
|
3
|
+
sbxloop/errors.py,sha256=ncVqK66R2ir1k5q9K_HcOYU7XAnWy6CbaJxxOjjX2UE,1847
|
|
4
|
+
sbxloop/events.py,sha256=DVVTk_v5GrDdHa8XbcKh20VLIivVoW3JIIMnuKjccSM,2499
|
|
5
|
+
sbxloop/ids.py,sha256=fbROK_3iWl8PahvlX33ucPO3d9p_5Kih63h5EexhA6c,847
|
|
6
|
+
sbxloop/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl,sha256=_0AhTlG7dc3IyGUI8lnzPgZcohyQv49uwvmdPXLiR7s,16994
|
|
8
|
+
sbxloop/cli/__init__.py,sha256=pjFZzGSZoDSr17oD0ay8d7jElyNl3YliaFDEgsnmKvE,42
|
|
9
|
+
sbxloop/cli/app.py,sha256=HupCo1DWL6fXpIP395ykBBEYXP7rr4ISdWPMqTDSyLs,12577
|
|
10
|
+
sbxloop/cli/doctor.py,sha256=4PfvOH6rg7kSigIwMlR0ACiM0c5BOqAPs0wOBuYnvoQ,5582
|
|
11
|
+
sbxloop/cli/tui.py,sha256=DE2AXG5ifL3VRelvU8S3a2RAbrAm15rbvZ4W4KnAzVY,7608
|
|
12
|
+
sbxloop/engine/__init__.py,sha256=87MAO9S35sn8k30mTPvOUJnDTHRjFNZOfAAu3wqQ2_M,461
|
|
13
|
+
sbxloop/engine/engine.py,sha256=cQO1lx58sZb5n2MpfbrOO1BD4o88-7Ryk6_Ft-HZwjE,14960
|
|
14
|
+
sbxloop/engine/model.py,sha256=ZwrwgOxvi6IfXS61eM_tGMYl0DijkogGfgJraA2ivzk,4312
|
|
15
|
+
sbxloop/engine/phases.py,sha256=jrlAlblGxKsaAApk4Kki16_H9IL8EqHrHA-vb0SchM0,8903
|
|
16
|
+
sbxloop/engine/store.py,sha256=YfsxQ5bp71rw6ukGHb4wH1NXkzdmIREduPeW78Xg8uk,8772
|
|
17
|
+
sbxloop/engine/prompts/__init__.py,sha256=GyVOTZ_QO0V27xdmV8sPbI3WK5911_ysiZrX_FMGw7E,937
|
|
18
|
+
sbxloop/engine/prompts/decompose.md,sha256=_oQ931PknBXW7mtNIQWrjWl1JNPr4vznicYaIj6v14I,1160
|
|
19
|
+
sbxloop/engine/prompts/execute.md,sha256=sFQDHFBwXd5snAi-i3rFdXe9PJPyR7Ry6SIrMOsSwVs,695
|
|
20
|
+
sbxloop/engine/prompts/plan.md,sha256=rgU5OSovIuW6BTBv90Y3QAKeC9o6fgX8rcytp81v7eM,770
|
|
21
|
+
sbxloop/engine/prompts/scrutinize.md,sha256=HMHlXiKXwIM4zf7vmI46GxMk8E0uk_BPBK7F-UVW3f8,1106
|
|
22
|
+
sbxloop/engine/prompts/validate.md,sha256=a8lAKaSoSz16qWJ2oNL5WXR7L4AxSeEbIRPoZlKyNYE,940
|
|
23
|
+
sbxloop/gh/__init__.py,sha256=fepcbuGH_PghbrMopE0hIkQ2M7AvQ79v8mmCEgieuOc,246
|
|
24
|
+
sbxloop/gh/ops.py,sha256=loeqsv98t3725DxrBvcPeoXbsRvC74-x8bJEoPioFsk,4205
|
|
25
|
+
sbxloop/gh/reporter.py,sha256=gNp5jxG9MofOo6A_xXkGL95mpso16ODpiLNCWUw9SpA,2732
|
|
26
|
+
sbxloop/sbx/__init__.py,sha256=BbxXDte1P8QUf8EEq1QvPdqdpLbUmh6tVOIh3ZD8dVw,264
|
|
27
|
+
sbxloop/sbx/cli.py,sha256=YaZwCY5iDRV5eKtcbyRxIsMEX0rqc5x3iFL8GU3MAxM,7764
|
|
28
|
+
sbxloop/sbx/models.py,sha256=czUotIcyPxz9RYYP19cuv8CYwePvO62Kw8oG55iyjr4,2410
|
|
29
|
+
sbxloop/sbx/pair.py,sha256=I7o9eOG7BexPxQFpcIW_OwM1YdAEmryYraiT2hYW7js,3961
|
|
30
|
+
sbxloop/sbx/parse.py,sha256=D3evxMonvYsw9Mx0ozyfKRC_IDt7vXMg-zs7Expozi8,2144
|
|
31
|
+
sbxloop/sbx/provision.py,sha256=0wPWtI9V2Od13tcDQDyzK6UR6-DFvOsPyZTfFwgZRK4,14866
|
|
32
|
+
sbxloop/sbx/sandbox.py,sha256=EYspkBKj5JEt-Y4Ma-D-608BEkEeckIRWDLzj-Gv0Oo,2613
|
|
33
|
+
sbxloop/worker/__init__.py,sha256=1YZ4g5YF6MBaH8Bt_ZZtEIM5ltdhRCu-iFReZ5LGcbE,227
|
|
34
|
+
sbxloop/worker/client.py,sha256=_3btiIM9OmtsCp9UBqBDQ1VfP7Wts-ZNj_OvsffBOto,15963
|
|
35
|
+
sbxloop/worker/wheel.py,sha256=vSFwQHRWVLo124pEHmKKji1_q1Tr4hRofEfKOb0aqV8,2564
|
|
36
|
+
sbxloop-0.2.0.dist-info/METADATA,sha256=-7sPQrRJjOtDldZEMYSWdadhr-wY9oZkeviVqGRxOw8,1269
|
|
37
|
+
sbxloop-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
38
|
+
sbxloop-0.2.0.dist-info/entry_points.txt,sha256=47smbBU306_uO80byyBBoEyx-8mo6vLf-FPL5vKX23o,49
|
|
39
|
+
sbxloop-0.2.0.dist-info/RECORD,,
|