gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
gpu4agents/core/paths.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Where gpu keeps its files: ``.gpu/`` in a project, ``~/.gpu`` per user.
|
|
2
|
+
|
|
3
|
+
Both were named ``.jambu`` before 0.3.0. A legacy directory is renamed in
|
|
4
|
+
place the first time it is needed, so an instance a project already tracks
|
|
5
|
+
is never forgotten (and never rented twice) after an upgrade.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
STATE_DIRNAME = ".gpu"
|
|
14
|
+
LEGACY_STATE_DIRNAME = ".jambu"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adopt_legacy(path: Path, legacy: Path) -> Path:
|
|
18
|
+
"""``path``, after moving ``legacy`` there when only the legacy one exists."""
|
|
19
|
+
if not path.exists() and legacy.is_dir():
|
|
20
|
+
try:
|
|
21
|
+
legacy.rename(path)
|
|
22
|
+
except OSError:
|
|
23
|
+
# Read-only or racing another gpu process: keep using the old one.
|
|
24
|
+
return path if path.exists() else legacy
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_state_dir(project_dir: Path) -> Path:
|
|
29
|
+
project_dir = Path(project_dir)
|
|
30
|
+
return adopt_legacy(project_dir / STATE_DIRNAME, project_dir / LEGACY_STATE_DIRNAME)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gpu_home() -> Path:
|
|
34
|
+
"""Per-user files: the project registry, sweep sightings, bad hosts, credentials.
|
|
35
|
+
|
|
36
|
+
``$GPU_HOME`` overrides it (``$JAMBU_HOME`` is still honored).
|
|
37
|
+
"""
|
|
38
|
+
override = os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME")
|
|
39
|
+
if override:
|
|
40
|
+
return Path(override).expanduser()
|
|
41
|
+
home = Path.home()
|
|
42
|
+
return adopt_legacy(home / STATE_DIRNAME, home / LEGACY_STATE_DIRNAME)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Lifecycle policy (spec sections 10-13, 25).
|
|
2
|
+
|
|
3
|
+
This module is the SINGLE SOURCE OF TRUTH for "may this instance stay
|
|
4
|
+
billable?". It is deliberately stdlib-only and dependency-free because its
|
|
5
|
+
source is inlined verbatim into the remote watchdog agent that runs on the
|
|
6
|
+
rented instance (see gpu4agents/watchdog/builder.py). Do not import anything
|
|
7
|
+
from the rest of the package here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
# --- normalized runtime state (spec section 8) -------------------------------
|
|
16
|
+
|
|
17
|
+
PROVISIONING = "provisioning"
|
|
18
|
+
STARTING = "starting"
|
|
19
|
+
READY = "ready"
|
|
20
|
+
BUSY = "busy"
|
|
21
|
+
IDLE = "idle"
|
|
22
|
+
STOPPED = "stopped"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
DESTROYED = "destroyed"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
# --- actions -----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
ACTION_NONE = "none"
|
|
30
|
+
ACTION_STOP = "stop"
|
|
31
|
+
ACTION_DESTROY = "destroy"
|
|
32
|
+
|
|
33
|
+
# --- reasons (recorded on every automatic shutdown, spec criterion 12) --------
|
|
34
|
+
|
|
35
|
+
REASON_IDLE = "idle_timeout"
|
|
36
|
+
REASON_MAX_LIFETIME = "max_lifetime"
|
|
37
|
+
REASON_BUDGET = "budget_exceeded"
|
|
38
|
+
REASON_STOPPED_TTL = "stopped_ttl"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Workload:
|
|
43
|
+
"""A registered unit of useful work (spec section 12)."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
started_at: float
|
|
47
|
+
last_heartbeat: float
|
|
48
|
+
label: str = ""
|
|
49
|
+
state: str = "running" # running | queued | finished | failed
|
|
50
|
+
|
|
51
|
+
def is_pending(self) -> bool:
|
|
52
|
+
return self.state in ("running", "queued")
|
|
53
|
+
|
|
54
|
+
def is_stale(self, now: float, grace: float) -> bool:
|
|
55
|
+
"""Heartbeat vanished for longer than the grace period (spec section 13)."""
|
|
56
|
+
if not self.is_pending():
|
|
57
|
+
return False
|
|
58
|
+
if self.state == "queued":
|
|
59
|
+
return False
|
|
60
|
+
return (now - self.last_heartbeat) > grace
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class PolicyConfig:
|
|
65
|
+
idle_timeout_s: Optional[float] = None
|
|
66
|
+
max_lifetime_s: Optional[float] = None
|
|
67
|
+
heartbeat_grace_s: float = 300.0
|
|
68
|
+
auto_stop: bool = True
|
|
69
|
+
auto_destroy: bool = False
|
|
70
|
+
allow_indefinite_lock: bool = False
|
|
71
|
+
stop_supported: bool = True
|
|
72
|
+
destroy_supported: bool = True
|
|
73
|
+
# False when a stopped instance keeps costing real money (spec section 25).
|
|
74
|
+
stop_eliminates_billing: bool = True
|
|
75
|
+
# Cost guardrails (spec section 17).
|
|
76
|
+
hourly_cost_usd: Optional[float] = None
|
|
77
|
+
max_session_cost_usd: Optional[float] = None
|
|
78
|
+
# A stopped instance still bills storage; destroy it after this long.
|
|
79
|
+
stopped_ttl_s: Optional[float] = None
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: dict) -> "PolicyConfig":
|
|
83
|
+
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
|
84
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict:
|
|
87
|
+
return {f: getattr(self, f) for f in self.__dataclass_fields__} # type: ignore[attr-defined]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Snapshot:
|
|
92
|
+
"""Everything the policy needs to decide. Times are unix seconds."""
|
|
93
|
+
|
|
94
|
+
now: float
|
|
95
|
+
created_at: float
|
|
96
|
+
last_activity: float
|
|
97
|
+
workloads: list = field(default_factory=list) # list[Workload]
|
|
98
|
+
starting: bool = False
|
|
99
|
+
locked: bool = False
|
|
100
|
+
locked_until: Optional[float] = None
|
|
101
|
+
provider_state: str = READY
|
|
102
|
+
# Start of the current container session. A restarted instance gets a new
|
|
103
|
+
# one, so max_lifetime and the budget do not kill it the moment it boots.
|
|
104
|
+
session_started_at: Optional[float] = None
|
|
105
|
+
# When the provider last reported the instance stopped (CLI side only).
|
|
106
|
+
stopped_since: Optional[float] = None
|
|
107
|
+
|
|
108
|
+
# -- derived helpers ----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def lock_active(self, cfg: PolicyConfig) -> bool:
|
|
111
|
+
if not self.locked:
|
|
112
|
+
return False
|
|
113
|
+
if self.locked_until is None:
|
|
114
|
+
# An indefinite lock only holds when the project explicitly allows it.
|
|
115
|
+
return bool(cfg.allow_indefinite_lock)
|
|
116
|
+
return self.locked_until > self.now
|
|
117
|
+
|
|
118
|
+
def lock_expired(self, cfg: PolicyConfig) -> bool:
|
|
119
|
+
if not self.locked:
|
|
120
|
+
return False
|
|
121
|
+
if self.locked_until is None:
|
|
122
|
+
return not cfg.allow_indefinite_lock
|
|
123
|
+
return self.locked_until <= self.now
|
|
124
|
+
|
|
125
|
+
def pending(self, cfg: PolicyConfig) -> list:
|
|
126
|
+
"""Workloads that still count as useful work."""
|
|
127
|
+
return [
|
|
128
|
+
w
|
|
129
|
+
for w in self.workloads
|
|
130
|
+
if w.is_pending() and not w.is_stale(self.now, cfg.heartbeat_grace_s)
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def stale(self, cfg: PolicyConfig) -> list:
|
|
134
|
+
return [w for w in self.workloads if w.is_stale(self.now, cfg.heartbeat_grace_s)]
|
|
135
|
+
|
|
136
|
+
def idle_seconds(self, cfg: PolicyConfig) -> float:
|
|
137
|
+
"""Seconds since the last thing that counted as activity."""
|
|
138
|
+
if self.pending(cfg) or self.starting:
|
|
139
|
+
return 0.0
|
|
140
|
+
marks = [self.last_activity]
|
|
141
|
+
for w in self.workloads:
|
|
142
|
+
marks.append(w.last_heartbeat)
|
|
143
|
+
return max(0.0, self.now - max(marks))
|
|
144
|
+
|
|
145
|
+
def age_seconds(self) -> float:
|
|
146
|
+
return max(0.0, self.now - self.created_at)
|
|
147
|
+
|
|
148
|
+
def session_age_seconds(self) -> float:
|
|
149
|
+
start = self.session_started_at if self.session_started_at is not None else self.created_at
|
|
150
|
+
return max(0.0, self.now - start)
|
|
151
|
+
|
|
152
|
+
def stopped_seconds(self) -> Optional[float]:
|
|
153
|
+
if self.stopped_since is None:
|
|
154
|
+
return None
|
|
155
|
+
return max(0.0, self.now - self.stopped_since)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Decision:
|
|
160
|
+
action: str = ACTION_NONE
|
|
161
|
+
reason: Optional[str] = None
|
|
162
|
+
detail: dict = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def terminates(self) -> bool:
|
|
166
|
+
return self.action in (ACTION_STOP, ACTION_DESTROY)
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict:
|
|
169
|
+
return {"action": self.action, "reason": self.reason, "detail": dict(self.detail)}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def derive_state(snapshot: Snapshot, cfg: PolicyConfig) -> str:
|
|
173
|
+
"""Normalized observed state of a live instance."""
|
|
174
|
+
if snapshot.provider_state in (STOPPED, DESTROYED, FAILED, PROVISIONING):
|
|
175
|
+
return snapshot.provider_state
|
|
176
|
+
if snapshot.starting:
|
|
177
|
+
return STARTING
|
|
178
|
+
if snapshot.pending(cfg):
|
|
179
|
+
return BUSY
|
|
180
|
+
if snapshot.provider_state == READY:
|
|
181
|
+
return IDLE if snapshot.idle_seconds(cfg) > 0 else READY
|
|
182
|
+
return snapshot.provider_state
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _terminal_action(cfg: PolicyConfig) -> str:
|
|
186
|
+
"""Pick the cheapest supported action that actually stops the bleeding."""
|
|
187
|
+
if cfg.auto_destroy:
|
|
188
|
+
return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
|
|
189
|
+
if not cfg.auto_stop:
|
|
190
|
+
return ACTION_NONE
|
|
191
|
+
if cfg.stop_supported and cfg.stop_eliminates_billing:
|
|
192
|
+
return ACTION_STOP
|
|
193
|
+
# Stopping does not (or cannot) end billing: escalate only when allowed to.
|
|
194
|
+
if not cfg.stop_supported:
|
|
195
|
+
return ACTION_DESTROY if cfg.destroy_supported else ACTION_NONE
|
|
196
|
+
return ACTION_STOP
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def evaluate(snapshot: Snapshot, cfg: PolicyConfig) -> Decision:
|
|
200
|
+
"""Core lifecycle rule (spec section 10). Pure function, no side effects."""
|
|
201
|
+
detail: dict[str, Any] = {
|
|
202
|
+
"state": derive_state(snapshot, cfg),
|
|
203
|
+
"age_seconds": round(snapshot.age_seconds(), 1),
|
|
204
|
+
"session_age_seconds": round(snapshot.session_age_seconds(), 1),
|
|
205
|
+
"idle_seconds": round(snapshot.idle_seconds(cfg), 1),
|
|
206
|
+
"pending_workloads": len(snapshot.pending(cfg)),
|
|
207
|
+
"stale_workloads": len(snapshot.stale(cfg)),
|
|
208
|
+
"locked": snapshot.lock_active(cfg),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
locked = snapshot.lock_active(cfg)
|
|
212
|
+
|
|
213
|
+
if snapshot.provider_state == STOPPED:
|
|
214
|
+
stopped_for = snapshot.stopped_seconds()
|
|
215
|
+
if (
|
|
216
|
+
cfg.stopped_ttl_s is not None
|
|
217
|
+
and stopped_for is not None
|
|
218
|
+
and stopped_for > cfg.stopped_ttl_s
|
|
219
|
+
and cfg.destroy_supported
|
|
220
|
+
and not locked
|
|
221
|
+
):
|
|
222
|
+
detail["stopped_seconds"] = round(stopped_for, 1)
|
|
223
|
+
return Decision(ACTION_DESTROY, REASON_STOPPED_TTL, detail)
|
|
224
|
+
return Decision(ACTION_NONE, "already_terminal", detail)
|
|
225
|
+
if snapshot.provider_state == DESTROYED:
|
|
226
|
+
return Decision(ACTION_NONE, "already_terminal", detail)
|
|
227
|
+
|
|
228
|
+
# max_lifetime wins over everything except an active lock. Measured per
|
|
229
|
+
# session: a restart is a deliberate new lease, not a forgotten instance.
|
|
230
|
+
if cfg.max_lifetime_s is not None and snapshot.session_age_seconds() > cfg.max_lifetime_s:
|
|
231
|
+
if locked:
|
|
232
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
233
|
+
action = _terminal_action(cfg)
|
|
234
|
+
if action == ACTION_NONE:
|
|
235
|
+
return Decision(ACTION_NONE, "max_lifetime_no_action", detail)
|
|
236
|
+
return Decision(action, REASON_MAX_LIFETIME, detail)
|
|
237
|
+
|
|
238
|
+
# Session budget ceiling.
|
|
239
|
+
if cfg.max_session_cost_usd is not None and cfg.hourly_cost_usd:
|
|
240
|
+
spent = cfg.hourly_cost_usd * snapshot.session_age_seconds() / 3600.0
|
|
241
|
+
detail["session_cost_usd"] = round(spent, 4)
|
|
242
|
+
if spent > cfg.max_session_cost_usd:
|
|
243
|
+
if locked:
|
|
244
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
245
|
+
action = _terminal_action(cfg)
|
|
246
|
+
if action != ACTION_NONE:
|
|
247
|
+
return Decision(action, REASON_BUDGET, detail)
|
|
248
|
+
|
|
249
|
+
if locked:
|
|
250
|
+
return Decision(ACTION_NONE, "locked", detail)
|
|
251
|
+
|
|
252
|
+
# Useful work in flight is never interrupted (spec criterion 5).
|
|
253
|
+
if snapshot.starting:
|
|
254
|
+
return Decision(ACTION_NONE, "startup_in_progress", detail)
|
|
255
|
+
if snapshot.pending(cfg):
|
|
256
|
+
return Decision(ACTION_NONE, "workload_active", detail)
|
|
257
|
+
|
|
258
|
+
if cfg.idle_timeout_s is not None and snapshot.idle_seconds(cfg) > cfg.idle_timeout_s:
|
|
259
|
+
action = _terminal_action(cfg)
|
|
260
|
+
if action == ACTION_NONE:
|
|
261
|
+
return Decision(ACTION_NONE, "idle_no_action", detail)
|
|
262
|
+
return Decision(action, REASON_IDLE, detail)
|
|
263
|
+
|
|
264
|
+
return Decision(ACTION_NONE, "within_policy", detail)
|