sbxloop-worker 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_worker-0.5.5 → sbxloop_worker-0.5.7}/PKG-INFO +1 -1
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/__main__.py +8 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/_version.py +2 -2
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/protocol.py +5 -0
- sbxloop_worker-0.5.7/src/sbxloop_worker/resources.py +77 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/runner.py +71 -0
- sbxloop_worker-0.5.7/tests/test_resources.py +178 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/.gitignore +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/README.md +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/pyproject.toml +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/__init__.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/_json.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/backends/__init__.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/backends/copilot.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/backends/echo.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/events.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/githubops.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/src/sbxloop_worker/py.typed +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/conftest.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/test_githubops.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/test_json_extract.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/test_protocol.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/test_runner_unit.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/tests/test_worker_contract.py +0 -0
- {sbxloop_worker-0.5.5 → sbxloop_worker-0.5.7}/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.
|
|
3
|
+
Version: 0.5.7
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|