sbxloop-worker 0.5.6__tar.gz → 0.5.8__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 (25) hide show
  1. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/PKG-INFO +1 -1
  2. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/__main__.py +8 -0
  3. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/_version.py +2 -2
  4. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/protocol.py +5 -0
  5. sbxloop_worker-0.5.8/src/sbxloop_worker/resources.py +77 -0
  6. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/runner.py +71 -0
  7. sbxloop_worker-0.5.8/tests/test_resources.py +178 -0
  8. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/.gitignore +0 -0
  9. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/README.md +0 -0
  10. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/pyproject.toml +0 -0
  11. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/__init__.py +0 -0
  12. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/_json.py +0 -0
  13. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/backends/__init__.py +0 -0
  14. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/backends/copilot.py +0 -0
  15. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/backends/echo.py +0 -0
  16. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/events.py +0 -0
  17. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/githubops.py +0 -0
  18. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/src/sbxloop_worker/py.typed +0 -0
  19. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/conftest.py +0 -0
  20. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/test_githubops.py +0 -0
  21. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/test_json_extract.py +0 -0
  22. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/test_protocol.py +0 -0
  23. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/test_runner_unit.py +0 -0
  24. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/test_worker_contract.py +0 -0
  25. {sbxloop_worker-0.5.6 → sbxloop_worker-0.5.8}/tests/worker_harness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop-worker
3
- Version: 0.5.6
3
+ Version: 0.5.8
4
4
  Summary: In-sandbox worker runtime for sbxloop (protocol models, agent backends, job runner)
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -66,6 +66,11 @@ def main(argv: list[str] | None = None) -> int:
66
66
  run.add_argument("--heartbeat", type=float, default=15.0)
67
67
  run.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
68
68
  run.add_argument("--cwd", type=Path, default=None)
69
+ # Resource guardrail thresholds (percent used; 0 disables). Sampling on
70
+ # the heartbeat always happens — thresholds only add warn/abort levels.
71
+ run.add_argument("--disk-warn", type=float, default=0.0)
72
+ run.add_argument("--disk-abort", type=float, default=0.0)
73
+ run.add_argument("--mem-warn", type=float, default=0.0)
69
74
  args = parser.parse_args(argv)
70
75
 
71
76
  apply_env_file(args.env_file)
@@ -95,6 +100,9 @@ def main(argv: list[str] | None = None) -> int:
95
100
  events_path=args.events,
96
101
  result_path=args.result,
97
102
  heartbeat_s=args.heartbeat,
103
+ disk_warn=args.disk_warn,
104
+ disk_abort=args.disk_abort,
105
+ mem_warn=args.mem_warn,
98
106
  ).run()
99
107
  except BaseException as exc:
100
108
  print(f"sbxloop_worker: fatal: {exc}", file=sys.stderr)
@@ -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.6'
22
- __version_tuple__ = version_tuple = (0, 5, 6)
21
+ __version__ = version = '0.5.8'
22
+ __version_tuple__ = version_tuple = (0, 5, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -193,3 +193,8 @@ class EventTypes:
193
193
 
194
194
  GH_OP_START = "gh.op_start"
195
195
  GH_OP_END = "gh.op_end"
196
+
197
+ # Resource telemetry sampled on the heartbeat cadence. Worker-emitted,
198
+ # but sandbox-scoped: the host enriches these with the sandbox role.
199
+ SANDBOX_RESOURCES = "sandbox.resources"
200
+ SANDBOX_RESOURCES_WARNING = "sandbox.resources_warning"
@@ -0,0 +1,77 @@
1
+ """Cheap in-sandbox resource sampling for the heartbeat cadence.
2
+
3
+ Everything here reads local kernel state (statvfs, /proc, loadavg) — no
4
+ subprocesses — so a sample is unobservable in run wall-clock. Fields that
5
+ cannot be read on the current platform are simply omitted; consumers treat
6
+ every field as optional.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ MEMINFO_PATH = Path("/proc/meminfo")
17
+
18
+ # Severity ordering for escalation edges (ok -> warn -> abort).
19
+ LEVEL_SEVERITY = {"ok": 0, "warn": 1, "abort": 2}
20
+
21
+
22
+ def sample_resources(path: str = ".") -> dict[str, Any]:
23
+ """One resource sample: disk usage of ``path``'s filesystem (the worker
24
+ runs with cwd = the run workspace), memory from /proc/meminfo, and the
25
+ 1-minute load average."""
26
+ sample: dict[str, Any] = {}
27
+ try:
28
+ st = os.statvfs(path)
29
+ total = st.f_blocks * st.f_frsize
30
+ free = st.f_bavail * st.f_frsize
31
+ if total > 0:
32
+ sample["disk_total_bytes"] = total
33
+ sample["disk_free_bytes"] = free
34
+ sample["disk_used_pct"] = round(100.0 * (1.0 - free / total), 1)
35
+ except OSError:
36
+ pass
37
+ try:
38
+ fields: dict[str, int] = {}
39
+ for line in MEMINFO_PATH.read_text().splitlines():
40
+ key, _, rest = line.partition(":")
41
+ if key in ("MemTotal", "MemAvailable"):
42
+ fields[key] = int(rest.split()[0]) # kB
43
+ total_kb = fields.get("MemTotal", 0)
44
+ available_kb = fields.get("MemAvailable")
45
+ if total_kb > 0 and available_kb is not None:
46
+ sample["mem_total_kb"] = total_kb
47
+ sample["mem_available_kb"] = available_kb
48
+ sample["mem_used_pct"] = round(100.0 * (1.0 - available_kb / total_kb), 1)
49
+ except (OSError, ValueError, IndexError):
50
+ pass
51
+ with contextlib.suppress(OSError):
52
+ sample["load1"] = round(os.getloadavg()[0], 2)
53
+ return sample
54
+
55
+
56
+ def classify_level(
57
+ sample: dict[str, Any],
58
+ *,
59
+ disk_warn: float = 0.0,
60
+ disk_abort: float = 0.0,
61
+ mem_warn: float = 0.0,
62
+ ) -> str:
63
+ """Guardrail level for a sample: "ok", "warn", or "abort".
64
+
65
+ A threshold of 0 (the worker default when the host passes none) disables
66
+ that guardrail. Only disk has an abort level — memory pressure warns.
67
+ """
68
+ disk = sample.get("disk_used_pct")
69
+ if isinstance(disk, (int, float)):
70
+ if disk_abort > 0 and disk >= disk_abort:
71
+ return "abort"
72
+ if disk_warn > 0 and disk >= disk_warn:
73
+ return "warn"
74
+ mem = sample.get("mem_used_pct")
75
+ if isinstance(mem, (int, float)) and mem_warn > 0 and mem >= mem_warn:
76
+ return "warn"
77
+ return "ok"
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from sbxloop_worker.backends import get_backend
11
11
  from sbxloop_worker.events import EventWriter
12
12
  from sbxloop_worker.protocol import ErrorInfo, EventTypes, JobRequest, JobResult
13
+ from sbxloop_worker.resources import LEVEL_SEVERITY, classify_level, sample_resources
13
14
 
14
15
  OUTPUT_TAIL_CHARS = 20_000
15
16
 
@@ -23,12 +24,20 @@ class JobRunner:
23
24
  *,
24
25
  heartbeat_s: float = 15.0,
25
26
  backend_name: str | None = None,
27
+ disk_warn: float = 0.0,
28
+ disk_abort: float = 0.0,
29
+ mem_warn: float = 0.0,
26
30
  ) -> None:
27
31
  self.job = job
28
32
  self.events_path = events_path
29
33
  self.result_path = result_path
30
34
  self.heartbeat_s = heartbeat_s
31
35
  self.backend_name = backend_name
36
+ self.disk_warn = disk_warn
37
+ self.disk_abort = disk_abort
38
+ self.mem_warn = mem_warn
39
+ self._resource_level = "ok"
40
+ self._resource_abort: str | None = None
32
41
 
33
42
  def run(self) -> JobResult:
34
43
  """Execute the job and write the authoritative result file.
@@ -39,6 +48,11 @@ class JobRunner:
39
48
  heartbeat_stop = self._start_heartbeat(writer)
40
49
  try:
41
50
  writer.emit(EventTypes.WORKER_START, kind=self.job.kind)
51
+ # Baseline resource sample: even a job that finishes inside
52
+ # one heartbeat gets a datapoint, and a sandbox already past
53
+ # a threshold is flagged before work starts.
54
+ if self.heartbeat_s > 0:
55
+ self._sample_and_emit(writer)
42
56
  result = self._dispatch(writer)
43
57
  except subprocess.TimeoutExpired:
44
58
  result = self._error_result("timeout", "Timeout", "job timed out")
@@ -52,6 +66,22 @@ class JobRunner:
52
66
  finally:
53
67
  heartbeat_stop.set()
54
68
 
69
+ if self._resource_abort and result.status != "ok":
70
+ # The sandbox blew past disk_abort while this job ran: name
71
+ # the real cause instead of whatever confusing failure the
72
+ # in-VM tooling produced on a full disk.
73
+ original = ""
74
+ if result.error is not None:
75
+ original = f"underlying failure: {result.error.type}: {result.error.message}"
76
+ if result.error.detail:
77
+ original += f"\n{result.error.detail}"
78
+ result = self._error_result(
79
+ "error",
80
+ "SandboxResourcesExhausted",
81
+ self._resource_abort,
82
+ detail=original[-OUTPUT_TAIL_CHARS:] or None,
83
+ )
84
+
55
85
  self.result_path.parent.mkdir(parents=True, exist_ok=True)
56
86
  self.result_path.write_text(result.model_dump_json())
57
87
  if result.status == "ok":
@@ -152,9 +182,50 @@ class JobRunner:
152
182
  while not stop.wait(self.heartbeat_s):
153
183
  try:
154
184
  writer.emit(EventTypes.WORKER_HEARTBEAT)
185
+ self._sample_and_emit(writer)
155
186
  except Exception: # pragma: no cover - writer closed during shutdown
156
187
  return
157
188
 
158
189
  thread = threading.Thread(target=beat, name="sbxloop-heartbeat", daemon=True)
159
190
  thread.start()
160
191
  return stop
192
+
193
+ def _sample_and_emit(self, writer: EventWriter) -> None:
194
+ """Emit one ``sandbox.resources`` sample; escalations additionally
195
+ emit a prominent warning event (edge-triggered, so a long run at 90%
196
+ disk produces one warning, not one per beat)."""
197
+ sample = sample_resources()
198
+ if not sample:
199
+ return
200
+ level = classify_level(
201
+ sample,
202
+ disk_warn=self.disk_warn,
203
+ disk_abort=self.disk_abort,
204
+ mem_warn=self.mem_warn,
205
+ )
206
+ writer.emit(EventTypes.SANDBOX_RESOURCES, level=level, **sample)
207
+ if LEVEL_SEVERITY[level] > LEVEL_SEVERITY[self._resource_level]:
208
+ writer.emit(
209
+ EventTypes.SANDBOX_RESOURCES_WARNING,
210
+ level=level,
211
+ message=self._level_message(level, sample),
212
+ **sample,
213
+ )
214
+ if level == "abort" and self._resource_abort is None:
215
+ self._resource_abort = self._level_message(level, sample)
216
+ self._resource_level = level
217
+
218
+ def _level_message(self, level: str, sample: dict[str, object]) -> str:
219
+ disk = sample.get("disk_used_pct")
220
+ mem = sample.get("mem_used_pct")
221
+ if level == "abort":
222
+ return (
223
+ f"sandbox disk exhausted: {disk}% of the workspace filesystem is used "
224
+ f"(disk_abort threshold: {self.disk_abort}%)"
225
+ )
226
+ parts = []
227
+ if isinstance(disk, (int, float)) and self.disk_warn > 0 and disk >= self.disk_warn:
228
+ parts.append(f"disk {disk}% used (disk_warn: {self.disk_warn}%)")
229
+ if isinstance(mem, (int, float)) and self.mem_warn > 0 and mem >= self.mem_warn:
230
+ parts.append(f"memory {mem}% used (mem_warn: {self.mem_warn}%)")
231
+ return "sandbox resources under pressure: " + ", ".join(parts)
@@ -0,0 +1,178 @@
1
+ """Resource telemetry tests: heartbeat sampling, guardrail levels, and the
2
+ disk-abort result rewrite (issue #54)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Any, ClassVar
8
+
9
+ import pytest
10
+
11
+ import sbxloop_worker.runner as runner_mod
12
+ from sbxloop_worker.protocol import Event, EventTypes, JobRequest
13
+ from sbxloop_worker.resources import classify_level, sample_resources
14
+ from sbxloop_worker.runner import JobRunner
15
+
16
+
17
+ def agent_job(**overrides: object) -> JobRequest:
18
+ base: dict[str, object] = {
19
+ "job_id": "j1",
20
+ "run_id": "r1",
21
+ "kind": "agent.session",
22
+ "prompt": "hello",
23
+ }
24
+ base.update(overrides)
25
+ return JobRequest.model_validate(base)
26
+
27
+
28
+ def failing_job() -> JobRequest:
29
+ # github.op with no params fails fast with GithubOpError — a stand-in
30
+ # for "whatever confusing way tooling fails on a full disk".
31
+ return JobRequest(job_id="j1", run_id="r1", kind="github.op", op="issue.create")
32
+
33
+
34
+ def run_with_sample(
35
+ tmp_path: Path,
36
+ monkeypatch: pytest.MonkeyPatch,
37
+ sample: dict[str, Any],
38
+ *,
39
+ job: JobRequest | None = None,
40
+ **runner_kwargs: float,
41
+ ) -> tuple[Any, list[Event]]:
42
+ """Run a job with a deterministic resource sample. heartbeat_s is large
43
+ so only the synchronous baseline sample fires."""
44
+ monkeypatch.setattr(runner_mod, "sample_resources", lambda path=".": dict(sample))
45
+ monkeypatch.setenv("SBXLOOP_WORKER_BACKEND", "echo")
46
+ events_path = tmp_path / "events.jsonl"
47
+ result = JobRunner(
48
+ job if job is not None else agent_job(),
49
+ events_path=events_path,
50
+ result_path=tmp_path / "result.json",
51
+ heartbeat_s=3600.0,
52
+ backend_name="echo",
53
+ **runner_kwargs,
54
+ ).run()
55
+ events = [Event.from_json_line(line) for line in events_path.read_text().splitlines()]
56
+ return result, events
57
+
58
+
59
+ class TestSampleResources:
60
+ def test_disk_fields_present(self) -> None:
61
+ sample = sample_resources(".")
62
+ assert 0.0 <= sample["disk_used_pct"] <= 100.0
63
+ assert sample["disk_total_bytes"] > 0
64
+ assert sample["disk_free_bytes"] >= 0
65
+
66
+ def test_unreadable_path_omits_disk(self) -> None:
67
+ sample = sample_resources("/nonexistent/definitely/nope")
68
+ assert "disk_used_pct" not in sample
69
+
70
+
71
+ class TestClassifyLevel:
72
+ LIMITS: ClassVar[dict[str, float]] = {"disk_warn": 85.0, "disk_abort": 95.0, "mem_warn": 90.0}
73
+
74
+ def test_zero_thresholds_disable(self) -> None:
75
+ assert classify_level({"disk_used_pct": 99.9, "mem_used_pct": 99.9}) == "ok"
76
+
77
+ def test_disk_levels(self) -> None:
78
+ assert classify_level({"disk_used_pct": 84.9}, **self.LIMITS) == "ok"
79
+ assert classify_level({"disk_used_pct": 85.0}, **self.LIMITS) == "warn"
80
+ assert classify_level({"disk_used_pct": 95.0}, **self.LIMITS) == "abort"
81
+
82
+ def test_memory_only_warns(self) -> None:
83
+ assert classify_level({"mem_used_pct": 99.0}, **self.LIMITS) == "warn"
84
+
85
+ def test_empty_sample_is_ok(self) -> None:
86
+ assert classify_level({}, **self.LIMITS) == "ok"
87
+
88
+
89
+ class TestRunnerTelemetry:
90
+ def test_baseline_sample_emitted_after_start(
91
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
92
+ ) -> None:
93
+ result, events = run_with_sample(tmp_path, monkeypatch, {"disk_used_pct": 10.0})
94
+ assert result.status == "ok"
95
+ types = [e.type for e in events]
96
+ assert types[0] == EventTypes.WORKER_START
97
+ assert EventTypes.SANDBOX_RESOURCES in types
98
+ sample_event = next(e for e in events if e.type == EventTypes.SANDBOX_RESOURCES)
99
+ assert sample_event.data["level"] == "ok"
100
+ assert sample_event.data["disk_used_pct"] == 10.0
101
+ assert EventTypes.SANDBOX_RESOURCES_WARNING not in types
102
+
103
+ def test_no_sampling_when_heartbeat_disabled(
104
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
105
+ ) -> None:
106
+ monkeypatch.setenv("SBXLOOP_WORKER_BACKEND", "echo")
107
+ events_path = tmp_path / "events.jsonl"
108
+ JobRunner(
109
+ agent_job(),
110
+ events_path=events_path,
111
+ result_path=tmp_path / "result.json",
112
+ heartbeat_s=0.0,
113
+ backend_name="echo",
114
+ disk_warn=85.0,
115
+ disk_abort=95.0,
116
+ ).run()
117
+ events = [Event.from_json_line(line) for line in events_path.read_text().splitlines()]
118
+ assert EventTypes.SANDBOX_RESOURCES not in [e.type for e in events]
119
+
120
+ def test_warn_threshold_emits_edge_triggered_warning(
121
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
122
+ ) -> None:
123
+ result, events = run_with_sample(
124
+ tmp_path,
125
+ monkeypatch,
126
+ {"disk_used_pct": 90.0},
127
+ disk_warn=85.0,
128
+ disk_abort=95.0,
129
+ )
130
+ assert result.status == "ok" # warn never fails a job
131
+ warnings = [e for e in events if e.type == EventTypes.SANDBOX_RESOURCES_WARNING]
132
+ assert len(warnings) == 1
133
+ assert warnings[0].data["level"] == "warn"
134
+ assert "disk 90.0% used" in warnings[0].data["message"]
135
+
136
+ def test_abort_rewrites_failed_result(
137
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
138
+ ) -> None:
139
+ result, events = run_with_sample(
140
+ tmp_path,
141
+ monkeypatch,
142
+ {"disk_used_pct": 97.0},
143
+ job=failing_job(),
144
+ disk_warn=85.0,
145
+ disk_abort=95.0,
146
+ )
147
+ assert result.status == "error"
148
+ assert result.error.type == "SandboxResourcesExhausted"
149
+ assert "sandbox disk exhausted" in result.error.message
150
+ assert "97.0%" in result.error.message
151
+ # The original failure is preserved for diagnosis, not discarded.
152
+ assert "GithubOpError" in (result.error.detail or "")
153
+ warnings = [e for e in events if e.type == EventTypes.SANDBOX_RESOURCES_WARNING]
154
+ assert warnings and warnings[0].data["level"] == "abort"
155
+
156
+ def test_abort_never_rewrites_success(
157
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
158
+ ) -> None:
159
+ result, _ = run_with_sample(
160
+ tmp_path,
161
+ monkeypatch,
162
+ {"disk_used_pct": 97.0},
163
+ disk_warn=85.0,
164
+ disk_abort=95.0,
165
+ )
166
+ assert result.status == "ok"
167
+
168
+ def test_mem_pressure_warns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
169
+ result, events = run_with_sample(
170
+ tmp_path,
171
+ monkeypatch,
172
+ {"disk_used_pct": 10.0, "mem_used_pct": 95.0},
173
+ mem_warn=90.0,
174
+ )
175
+ assert result.status == "ok"
176
+ warnings = [e for e in events if e.type == EventTypes.SANDBOX_RESOURCES_WARNING]
177
+ assert len(warnings) == 1
178
+ assert "memory 95.0% used" in warnings[0].data["message"]
File without changes