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.
Files changed (59) hide show
  1. gpu4agents/__init__.py +5 -0
  2. gpu4agents/__main__.py +4 -0
  3. gpu4agents/agent/__init__.py +1 -0
  4. gpu4agents/agent/install.py +173 -0
  5. gpu4agents/agent/jobs.py +193 -0
  6. gpu4agents/agent/mcp.py +646 -0
  7. gpu4agents/agent/skill/SKILL.md +68 -0
  8. gpu4agents/cli/__init__.py +0 -0
  9. gpu4agents/cli/_common.py +131 -0
  10. gpu4agents/cli/account.py +330 -0
  11. gpu4agents/cli/agent.py +74 -0
  12. gpu4agents/cli/inspect.py +207 -0
  13. gpu4agents/cli/lifecycle.py +127 -0
  14. gpu4agents/cli/main.py +163 -0
  15. gpu4agents/cli/providers.py +96 -0
  16. gpu4agents/cli/run.py +38 -0
  17. gpu4agents/cli/setup.py +164 -0
  18. gpu4agents/cli/status.py +224 -0
  19. gpu4agents/cli/timer.py +143 -0
  20. gpu4agents/cli/validate.py +57 -0
  21. gpu4agents/core/__init__.py +0 -0
  22. gpu4agents/core/beacon.py +133 -0
  23. gpu4agents/core/config.py +647 -0
  24. gpu4agents/core/credentials.py +95 -0
  25. gpu4agents/core/durations.py +47 -0
  26. gpu4agents/core/engine.py +1335 -0
  27. gpu4agents/core/errors.py +94 -0
  28. gpu4agents/core/events.py +117 -0
  29. gpu4agents/core/execution.py +169 -0
  30. gpu4agents/core/health.py +62 -0
  31. gpu4agents/core/hosts.py +58 -0
  32. gpu4agents/core/model_inspect.py +368 -0
  33. gpu4agents/core/models.py +313 -0
  34. gpu4agents/core/paths.py +42 -0
  35. gpu4agents/core/policy.py +264 -0
  36. gpu4agents/core/state.py +406 -0
  37. gpu4agents/core/sweep.py +478 -0
  38. gpu4agents/core/tool_parsers.py +63 -0
  39. gpu4agents/providers/__init__.py +4 -0
  40. gpu4agents/providers/base.py +137 -0
  41. gpu4agents/providers/registry.py +63 -0
  42. gpu4agents/providers/vast/__init__.py +3 -0
  43. gpu4agents/providers/vast/client.py +255 -0
  44. gpu4agents/providers/vast/config.py +56 -0
  45. gpu4agents/providers/vast/mapper.py +292 -0
  46. gpu4agents/providers/vast/provider.py +438 -0
  47. gpu4agents/runtime/__init__.py +6 -0
  48. gpu4agents/runtime/base.py +184 -0
  49. gpu4agents/runtime/vllm.py +295 -0
  50. gpu4agents/watchdog/__init__.py +3 -0
  51. gpu4agents/watchdog/agent_body.py +959 -0
  52. gpu4agents/watchdog/builder.py +248 -0
  53. gpu4agents-0.5.0.dist-info/METADATA +157 -0
  54. gpu4agents-0.5.0.dist-info/RECORD +59 -0
  55. gpu4agents-0.5.0.dist-info/WHEEL +4 -0
  56. gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
  57. gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
  58. jambu_gpu/__init__.py +9 -0
  59. jambu_gpu/__main__.py +4 -0
@@ -0,0 +1,406 @@
1
+ """Observed runtime state (spec section 9).
2
+
3
+ jambu.yaml is the desired-state SoT; ``.gpu/state.json`` is what we last
4
+ observed. This module owns reading/writing it atomically, plus the on-disk
5
+ process lock that keeps two CLI invocations from provisioning twice.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from contextlib import contextmanager
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Iterable, Iterator, Optional
21
+
22
+ from .errors import LockBusy, StateError
23
+ from .models import Instance, InstanceState
24
+ from .policy import Workload as PolicyWorkload
25
+
26
+ STATE_VERSION = 1
27
+
28
+
29
+ def utc_now() -> float:
30
+ return time.time()
31
+
32
+
33
+ def to_iso(ts: Optional[float]) -> Optional[str]:
34
+ if ts is None:
35
+ return None
36
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds").replace(
37
+ "+00:00", "Z"
38
+ )
39
+
40
+
41
+ def from_iso(value: Any) -> Optional[float]:
42
+ if value is None or value == "":
43
+ return None
44
+ if isinstance(value, (int, float)):
45
+ return float(value)
46
+ text = str(value).replace("Z", "+00:00")
47
+ try:
48
+ return datetime.fromisoformat(text).timestamp()
49
+ except ValueError as exc:
50
+ raise StateError(f"invalid timestamp in state file: {value!r}") from exc
51
+
52
+
53
+ @dataclass
54
+ class WorkloadRecord:
55
+ id: str
56
+ label: str = ""
57
+ command: list[str] = field(default_factory=list)
58
+ state: str = "running" # running | queued | finished | failed
59
+ started_at: float = field(default_factory=utc_now)
60
+ last_heartbeat: float = field(default_factory=utc_now)
61
+ finished_at: Optional[float] = None
62
+ returncode: Optional[int] = None
63
+ pid: Optional[int] = None
64
+
65
+ def to_policy(self) -> PolicyWorkload:
66
+ return PolicyWorkload(
67
+ id=self.id,
68
+ started_at=self.started_at,
69
+ last_heartbeat=self.last_heartbeat,
70
+ label=self.label,
71
+ state=self.state,
72
+ )
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ data = asdict(self)
76
+ data["started_at"] = to_iso(self.started_at)
77
+ data["last_heartbeat"] = to_iso(self.last_heartbeat)
78
+ data["finished_at"] = to_iso(self.finished_at)
79
+ return data
80
+
81
+ @classmethod
82
+ def from_dict(cls, data: dict[str, Any]) -> "WorkloadRecord":
83
+ return cls(
84
+ id=data["id"],
85
+ label=data.get("label", ""),
86
+ command=list(data.get("command") or []),
87
+ state=data.get("state", "running"),
88
+ started_at=from_iso(data.get("started_at")) or utc_now(),
89
+ last_heartbeat=from_iso(data.get("last_heartbeat")) or utc_now(),
90
+ finished_at=from_iso(data.get("finished_at")),
91
+ returncode=data.get("returncode"),
92
+ pid=data.get("pid"),
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class WatchdogInfo:
98
+ installed: bool = False
99
+ url: Optional[str] = None
100
+ token: Optional[str] = None
101
+ port: Optional[int] = None
102
+ last_contact: Optional[float] = None
103
+ last_error: Optional[str] = None
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ data = asdict(self)
107
+ data["last_contact"] = to_iso(self.last_contact)
108
+ return data
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[str, Any]) -> "WatchdogInfo":
112
+ return cls(
113
+ installed=bool(data.get("installed")),
114
+ url=data.get("url"),
115
+ token=data.get("token"),
116
+ port=data.get("port"),
117
+ last_contact=from_iso(data.get("last_contact")),
118
+ last_error=data.get("last_error"),
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class RuntimeState:
124
+ provider: str = ""
125
+ instance_id: Optional[str] = None
126
+ fingerprint: Optional[str] = None
127
+ state: str = InstanceState.DESTROYED.value
128
+ created_at: Optional[float] = None
129
+ last_activity: Optional[float] = None
130
+ locked: bool = False
131
+ locked_until: Optional[float] = None
132
+ lock_reason: str = ""
133
+ starting: bool = False
134
+ model_id: Optional[str] = None
135
+ endpoint: Optional[str] = None
136
+ hourly_cost_usd: Optional[float] = None
137
+ session_cost_usd: float = 0.0
138
+ # Billable-time bookkeeping (spec section 25): session_cost_usd must
139
+ # reflect actual running time, not wall-clock time since creation - a
140
+ # stopped instance costs nothing in compute-hours even if `status` is
141
+ # checked hours later. `billed_seconds` accumulates completed billable
142
+ # periods (e.g. before a stop); `running_since` marks an open one.
143
+ billed_seconds: float = 0.0
144
+ running_since: Optional[float] = None
145
+ # A session is one continuous running period of the instance: it starts
146
+ # at creation and again at every restart. max_lifetime and the session
147
+ # budget are measured from here, so a restarted instance gets a fresh
148
+ # bound instead of being killed for the hours it spent stopped.
149
+ session_started_at: Optional[float] = None
150
+ # When the instance was first observed STOPPED; drives lifecycle.stopped_ttl
151
+ # on providers where a stopped instance still bills (storage).
152
+ stopped_since: Optional[float] = None
153
+ # The key the model server demands (vLLM --api-key): runtime.api_key, or
154
+ # one generated per instance so a public endpoint is never open to anyone.
155
+ api_key: Optional[str] = None
156
+ instance: Optional[Instance] = None
157
+ workloads: dict[str, WorkloadRecord] = field(default_factory=dict)
158
+ watchdog: WatchdogInfo = field(default_factory=WatchdogInfo)
159
+
160
+ # -- helpers ------------------------------------------------------------
161
+
162
+ def has_instance(self) -> bool:
163
+ return bool(self.instance_id) and self.state != InstanceState.DESTROYED.value
164
+
165
+ def touch(self) -> None:
166
+ self.last_activity = utc_now()
167
+
168
+ def accrued_billable_seconds(self, now: Optional[float] = None) -> float:
169
+ """Total seconds actually spent in a billable-compute state.
170
+
171
+ Unlike ``now - created_at``, this excludes time spent stopped -
172
+ checking `status` long after an instance auto-stopped must not keep
173
+ inflating the reported cost.
174
+ """
175
+ now = now if now is not None else utc_now()
176
+ open_period = max(0.0, now - self.running_since) if self.running_since is not None else 0.0
177
+ return self.billed_seconds + open_period
178
+
179
+ def mark_billable(self, is_billable: bool, now: Optional[float] = None) -> None:
180
+ """Record a transition into/out of a billable-compute state.
181
+
182
+ Call this every time reconciliation observes the current state -
183
+ it's a no-op unless billable-ness actually changed since the last call.
184
+ """
185
+ now = now if now is not None else utc_now()
186
+ was_billable = self.running_since is not None
187
+ if is_billable and not was_billable:
188
+ self.running_since = now
189
+ elif not is_billable and was_billable:
190
+ self.billed_seconds += max(0.0, now - self.running_since)
191
+ self.running_since = None
192
+
193
+ def mark_stopped(self, now: Optional[float] = None, ran_at: Iterable[Optional[float]] = ()) -> None:
194
+ """Record that the instance is (still) stopped; keeps the earliest time.
195
+
196
+ ``ran_at`` are times the instance was evidently running. One newer than
197
+ ``stopped_since`` means it ran (and stopped again) while this CLI was
198
+ not looking, so the stop clock restarts instead of destroying a fresh stop.
199
+ """
200
+ now = now if now is not None else utc_now()
201
+ if self.stopped_since is None:
202
+ self.stopped_since = now
203
+ return
204
+ evidence = [
205
+ self.session_started_at,
206
+ self.last_activity,
207
+ self.watchdog.last_contact,
208
+ *ran_at,
209
+ ]
210
+ # The state file keeps whole seconds; a sub-second lead is rounding.
211
+ if any(ts is not None and ts > self.stopped_since + 1.0 for ts in evidence):
212
+ self.stopped_since = now
213
+
214
+ def mark_session_start(self, now: Optional[float] = None) -> None:
215
+ self.session_started_at = now if now is not None else utc_now()
216
+ self.stopped_since = None
217
+
218
+ def pending_workloads(self) -> list[WorkloadRecord]:
219
+ return [w for w in self.workloads.values() if w.state in ("running", "queued")]
220
+
221
+ def prune_workloads(self, keep: int = 25) -> None:
222
+ finished = sorted(
223
+ (w for w in self.workloads.values() if w.state not in ("running", "queued")),
224
+ key=lambda w: w.finished_at or w.started_at,
225
+ )
226
+ for record in finished[:-keep] if len(finished) > keep else []:
227
+ self.workloads.pop(record.id, None)
228
+
229
+ def clear_instance(self, state: str = InstanceState.DESTROYED.value) -> None:
230
+ # Close out any open billable period and freeze the final total into
231
+ # session_cost_usd (the historical record of the finished session)
232
+ # before resetting the counters for whatever instance comes next.
233
+ self.mark_billable(False)
234
+ if self.hourly_cost_usd:
235
+ self.session_cost_usd = round(self.hourly_cost_usd * self.billed_seconds / 3600.0, 4)
236
+ self.instance_id = None
237
+ self.instance = None
238
+ self.endpoint = None
239
+ self.api_key = None
240
+ self.state = state
241
+ self.starting = False
242
+ self.locked = False
243
+ self.locked_until = None
244
+ self.watchdog = WatchdogInfo()
245
+ self.billed_seconds = 0.0
246
+ self.running_since = None
247
+ self.session_started_at = None
248
+ self.stopped_since = None
249
+ for record in self.workloads.values():
250
+ if record.state in ("running", "queued"):
251
+ record.state = "failed"
252
+ record.finished_at = utc_now()
253
+
254
+ # -- serialization ------------------------------------------------------
255
+
256
+ def to_dict(self) -> dict[str, Any]:
257
+ return {
258
+ "version": STATE_VERSION,
259
+ "provider": self.provider,
260
+ "instance_id": self.instance_id,
261
+ "fingerprint": self.fingerprint,
262
+ "state": self.state,
263
+ "created_at": to_iso(self.created_at),
264
+ "last_activity": to_iso(self.last_activity),
265
+ "locked": self.locked,
266
+ "locked_until": to_iso(self.locked_until),
267
+ "lock_reason": self.lock_reason,
268
+ "starting": self.starting,
269
+ "model_id": self.model_id,
270
+ "endpoint": self.endpoint,
271
+ "hourly_cost_usd": self.hourly_cost_usd,
272
+ "session_cost_usd": round(self.session_cost_usd, 4),
273
+ "billed_seconds": round(self.billed_seconds, 1),
274
+ "running_since": to_iso(self.running_since),
275
+ "session_started_at": to_iso(self.session_started_at),
276
+ "stopped_since": to_iso(self.stopped_since),
277
+ "api_key": self.api_key,
278
+ "instance": self.instance.to_dict() if self.instance else None,
279
+ "workloads": {k: v.to_dict() for k, v in self.workloads.items()},
280
+ "watchdog": self.watchdog.to_dict(),
281
+ }
282
+
283
+ @classmethod
284
+ def from_dict(cls, data: dict[str, Any]) -> "RuntimeState":
285
+ instance_data = data.get("instance")
286
+ return cls(
287
+ provider=data.get("provider", ""),
288
+ instance_id=data.get("instance_id"),
289
+ fingerprint=data.get("fingerprint"),
290
+ state=data.get("state", InstanceState.DESTROYED.value),
291
+ created_at=from_iso(data.get("created_at")),
292
+ last_activity=from_iso(data.get("last_activity")),
293
+ locked=bool(data.get("locked")),
294
+ locked_until=from_iso(data.get("locked_until")),
295
+ lock_reason=data.get("lock_reason", ""),
296
+ starting=bool(data.get("starting")),
297
+ model_id=data.get("model_id"),
298
+ endpoint=data.get("endpoint"),
299
+ hourly_cost_usd=data.get("hourly_cost_usd"),
300
+ session_cost_usd=float(data.get("session_cost_usd") or 0.0),
301
+ billed_seconds=float(data.get("billed_seconds") or 0.0),
302
+ running_since=from_iso(data.get("running_since")),
303
+ session_started_at=from_iso(data.get("session_started_at")),
304
+ stopped_since=from_iso(data.get("stopped_since")),
305
+ api_key=data.get("api_key"),
306
+ instance=Instance.from_dict(instance_data) if instance_data else None,
307
+ workloads={
308
+ k: WorkloadRecord.from_dict(v) for k, v in (data.get("workloads") or {}).items()
309
+ },
310
+ watchdog=WatchdogInfo.from_dict(data.get("watchdog") or {}),
311
+ )
312
+
313
+
314
+ class StateStore:
315
+ """Atomic reader/writer for ``.gpu/state.json``."""
316
+
317
+ def __init__(self, state_dir: Path) -> None:
318
+ self._depth = 0 # flock is per-open-file-description: keep it reentrant
319
+ self.dir = Path(state_dir)
320
+ self.path = self.dir / "state.json"
321
+ self.lock_path = self.dir / "runtime.lock"
322
+ self.logs_dir = self.dir / "logs"
323
+
324
+ def ensure_dirs(self) -> None:
325
+ self.dir.mkdir(parents=True, exist_ok=True)
326
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
327
+ gitignore = self.dir / ".gitignore"
328
+ if not gitignore.exists():
329
+ gitignore.write_text("*\n")
330
+
331
+ def exists(self) -> bool:
332
+ return self.path.is_file()
333
+
334
+ def load(self) -> RuntimeState:
335
+ if not self.path.is_file():
336
+ return RuntimeState()
337
+ try:
338
+ data = json.loads(self.path.read_text() or "{}")
339
+ except json.JSONDecodeError as exc:
340
+ raise StateError(f"{self.path} is corrupt: {exc}") from exc
341
+ if not data:
342
+ return RuntimeState()
343
+ return RuntimeState.from_dict(data)
344
+
345
+ def save(self, state: RuntimeState) -> None:
346
+ self.ensure_dirs()
347
+ payload = json.dumps(state.to_dict(), indent=2, sort_keys=False) + "\n"
348
+ fd, tmp = tempfile.mkstemp(dir=str(self.dir), prefix=".state-", suffix=".json")
349
+ try:
350
+ with os.fdopen(fd, "w") as handle:
351
+ handle.write(payload)
352
+ handle.flush()
353
+ os.fsync(handle.fileno())
354
+ os.replace(tmp, self.path)
355
+ finally:
356
+ if os.path.exists(tmp):
357
+ os.unlink(tmp)
358
+
359
+ @contextmanager
360
+ def transaction(self, timeout: float = 30.0) -> Iterator[RuntimeState]:
361
+ """Exclusive read-modify-write. Guarantees `setup` cannot race itself."""
362
+ with self.process_lock(timeout=timeout):
363
+ state = self.load()
364
+ yield state
365
+ self.save(state)
366
+
367
+ @contextmanager
368
+ def process_lock(self, timeout: float = 30.0) -> Iterator[None]:
369
+ if self._depth > 0:
370
+ self._depth += 1
371
+ try:
372
+ yield
373
+ finally:
374
+ self._depth -= 1
375
+ return
376
+ self.ensure_dirs()
377
+ handle = open(self.lock_path, "a+")
378
+ deadline = time.time() + timeout
379
+ try:
380
+ while True:
381
+ try:
382
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
383
+ break
384
+ except BlockingIOError:
385
+ if time.time() >= deadline:
386
+ raise LockBusy(
387
+ "another gpu command is holding the runtime lock "
388
+ f"({self.lock_path}); retry when it finishes"
389
+ ) from None
390
+ time.sleep(0.2)
391
+ handle.seek(0)
392
+ handle.truncate()
393
+ handle.write(f"{os.getpid()} {to_iso(utc_now())}\n")
394
+ handle.flush()
395
+ self._depth = 1
396
+ yield
397
+ finally:
398
+ self._depth = 0
399
+ try:
400
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
401
+ finally:
402
+ handle.close()
403
+
404
+
405
+ def new_workload_id() -> str:
406
+ return uuid.uuid4().hex[:12]