sbxloop 0.5.6__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-0.5.6 → sbxloop-0.5.7}/PKG-INFO +1 -1
- sbxloop-0.5.7/src/sbxloop/_vendor/sbxloop_worker-0.5.7-py3-none-any.whl +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/app.py +9 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/doctor.py +23 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/tui.py +42 -4
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/config.py +38 -1
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/engine.py +53 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/worker/client.py +22 -1
- sbxloop-0.5.6/src/sbxloop/_vendor/sbxloop_worker-0.5.6-py3-none-any.whl +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/.gitignore +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/README.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/hatch_build.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/pyproject.toml +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/model.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/events.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/provision.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.7}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.5.6 → 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.
|
|
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
|
|
Binary file
|
|
@@ -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
|
|
@@ -655,6 +655,15 @@ max_replans_per_task = 1
|
|
|
655
655
|
max_tasks = 20
|
|
656
656
|
max_wall_clock_s = 7200.0
|
|
657
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
|
|
658
667
|
"""
|
|
659
668
|
|
|
660
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 ==
|
|
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
|
|
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
|
|
@@ -100,6 +136,7 @@ class Config(_ConfigModel):
|
|
|
100
136
|
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
101
137
|
github: GithubConfig = Field(default_factory=GithubConfig)
|
|
102
138
|
budgets: Budgets = Field(default_factory=Budgets)
|
|
139
|
+
limits: Limits = Field(default_factory=Limits)
|
|
103
140
|
|
|
104
141
|
|
|
105
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:
|
|
@@ -124,6 +137,8 @@ class LoopEngine:
|
|
|
124
137
|
self.bus,
|
|
125
138
|
transport=self.config.worker_transport,
|
|
126
139
|
python=self.worker_python,
|
|
140
|
+
role="agent",
|
|
141
|
+
limits=self.config.limits,
|
|
127
142
|
)
|
|
128
143
|
github = (
|
|
129
144
|
WorkerClient(
|
|
@@ -131,6 +146,8 @@ class LoopEngine:
|
|
|
131
146
|
self.bus,
|
|
132
147
|
transport=self.config.worker_transport,
|
|
133
148
|
python=self.worker_python,
|
|
149
|
+
role="github",
|
|
150
|
+
limits=self.config.limits,
|
|
134
151
|
)
|
|
135
152
|
if pair.github is not None
|
|
136
153
|
else None
|
|
@@ -249,12 +266,28 @@ class LoopEngine:
|
|
|
249
266
|
if target is None or not target.is_dir():
|
|
250
267
|
return
|
|
251
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
|
+
)
|
|
252
284
|
self.bus.emit(
|
|
253
285
|
HostEventTypes.RUN_ARTIFACTS,
|
|
254
286
|
run_id,
|
|
255
287
|
path=str(target),
|
|
256
288
|
files=count,
|
|
257
289
|
mounted=pair.mounted,
|
|
290
|
+
**extra,
|
|
258
291
|
)
|
|
259
292
|
|
|
260
293
|
def _deliver(
|
|
@@ -374,6 +407,14 @@ class LoopEngine:
|
|
|
374
407
|
|
|
375
408
|
while not task.terminal:
|
|
376
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
|
|
377
418
|
if task.state == "planning":
|
|
378
419
|
self._phase_plan(run_id, phases, task)
|
|
379
420
|
elif task.state == "executing":
|
|
@@ -510,6 +551,18 @@ class LoopEngine:
|
|
|
510
551
|
|
|
511
552
|
# -- bookkeeping -------------------------------------------------------
|
|
512
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
|
+
|
|
513
566
|
def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
|
|
514
567
|
if self.store.get_run(run_id).state == "cancelled":
|
|
515
568
|
raise StateError(f"run {run_id} was cancelled")
|
|
@@ -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 ----------------------------------------------------
|
|
Binary file
|
|
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
|
|
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
|