jambu-gpu 0.1.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 (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,68 @@
1
+ """Normalized error model (spec section 22).
2
+
3
+ Provider adapters MUST translate their native failures into these types so the
4
+ CLI behaves identically regardless of which provider is configured.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class JambuError(Exception):
11
+ """Base class for every error raised by the CLI."""
12
+
13
+ exit_code = 1
14
+
15
+
16
+ class ConfigError(JambuError):
17
+ """jambu.yaml (or legacy config.yml) is missing, malformed or semantically invalid."""
18
+
19
+ exit_code = 2
20
+
21
+
22
+ class StateError(JambuError):
23
+ """Local runtime state is missing or inconsistent."""
24
+
25
+ exit_code = 3
26
+
27
+
28
+ class ProviderError(JambuError):
29
+ """Base class for every provider-originated failure."""
30
+
31
+ exit_code = 4
32
+
33
+
34
+ class AuthenticationError(ProviderError):
35
+ exit_code = 5
36
+
37
+
38
+ class CapacityUnavailableError(ProviderError):
39
+ exit_code = 6
40
+
41
+
42
+ class ProvisioningError(ProviderError):
43
+ exit_code = 7
44
+
45
+
46
+ class ProviderTimeoutError(ProviderError):
47
+ exit_code = 8
48
+
49
+
50
+ class UnsupportedCapabilityError(ProviderError):
51
+ exit_code = 9
52
+
53
+
54
+ class BudgetExceededError(JambuError):
55
+ exit_code = 10
56
+
57
+
58
+ class RuntimeStartupError(JambuError):
59
+ exit_code = 11
60
+
61
+
62
+ class WorkloadFailed(JambuError):
63
+ """The user workload exited non-zero. Carries the original exit code."""
64
+
65
+ def __init__(self, message: str, returncode: int) -> None:
66
+ super().__init__(message)
67
+ self.returncode = returncode
68
+ self.exit_code = returncode or 1
@@ -0,0 +1,115 @@
1
+ """Structured lifecycle events (spec section 24).
2
+
3
+ Every lifecycle action appends one JSON object to ``.jambu/logs/events.jsonl``
4
+ so unexpected GPU cost can always be traced back to a decision and a reason.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any, Iterable, Optional
15
+
16
+ from .state import to_iso, utc_now
17
+
18
+ # Minimum event vocabulary required by the spec.
19
+ PROVISION_REQUESTED = "provision_requested"
20
+ INSTANCE_CREATED = "instance_created"
21
+ RUNTIME_STARTING = "runtime_starting"
22
+ RUNTIME_READY = "runtime_ready"
23
+ WORKLOAD_STARTED = "workload_started"
24
+ WORKLOAD_FINISHED = "workload_finished"
25
+ INSTANCE_IDLE = "instance_idle"
26
+ INSTANCE_LOCKED = "instance_locked"
27
+ INSTANCE_UNLOCKED = "instance_unlocked"
28
+ INSTANCE_STOPPED = "instance_stopped"
29
+ INSTANCE_STARTED = "instance_started"
30
+ INSTANCE_DESTROYED = "instance_destroyed"
31
+ LIFECYCLE_GUARD_TRIGGERED = "lifecycle_guard_triggered"
32
+ PROVIDER_ERROR = "provider_error"
33
+ STATE_RECONCILED = "state_reconciled"
34
+ SETUP_FAILED = "setup_failed"
35
+ CLEANUP_PERFORMED = "cleanup_performed"
36
+ BUDGET_REJECTED = "budget_rejected"
37
+
38
+
39
+ class EventLog:
40
+ """Append-only JSONL sink. Never raises into the caller's path."""
41
+
42
+ def __init__(self, logs_dir: Path, echo: bool = False) -> None:
43
+ self.dir = Path(logs_dir)
44
+ self.path = self.dir / "events.jsonl"
45
+ self.echo = echo
46
+ self._lock = threading.Lock()
47
+ self._context: dict[str, Any] = {}
48
+
49
+ def bind(self, **context: Any) -> "EventLog":
50
+ self._context.update({k: v for k, v in context.items() if v is not None})
51
+ return self
52
+
53
+ def emit(self, event: str, **fields: Any) -> dict[str, Any]:
54
+ record: dict[str, Any] = {
55
+ "ts": to_iso(utc_now()),
56
+ "event": event,
57
+ "pid": os.getpid(),
58
+ }
59
+ record.update(self._context)
60
+ record.update({k: v for k, v in fields.items() if v is not None})
61
+ line = json.dumps(record, default=str)
62
+ with self._lock:
63
+ try:
64
+ self.dir.mkdir(parents=True, exist_ok=True)
65
+ with open(self.path, "a") as handle:
66
+ handle.write(line + "\n")
67
+ except OSError:
68
+ pass
69
+ if self.echo:
70
+ print(line, file=sys.stderr)
71
+ return record
72
+
73
+ def tail(self, limit: int = 50, event: Optional[str] = None) -> list[dict[str, Any]]:
74
+ if not self.path.is_file():
75
+ return []
76
+ out: list[dict[str, Any]] = []
77
+ for line in self.path.read_text().splitlines():
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ try:
82
+ record = json.loads(line)
83
+ except json.JSONDecodeError:
84
+ continue
85
+ if event and record.get("event") != event:
86
+ continue
87
+ out.append(record)
88
+ return out[-limit:]
89
+
90
+ def extend(self, records: Iterable[dict[str, Any]]) -> int:
91
+ """Merge records produced elsewhere (e.g. pulled from the remote watchdog)."""
92
+ count = 0
93
+ seen = {(r.get("ts"), r.get("event")) for r in self.tail(limit=2000)}
94
+ with self._lock:
95
+ try:
96
+ self.dir.mkdir(parents=True, exist_ok=True)
97
+ with open(self.path, "a") as handle:
98
+ for record in records:
99
+ key = (record.get("ts"), record.get("event"))
100
+ if key in seen:
101
+ continue
102
+ seen.add(key)
103
+ handle.write(json.dumps(record, default=str) + "\n")
104
+ count += 1
105
+ except OSError:
106
+ pass
107
+ return count
108
+
109
+
110
+ class NullEventLog(EventLog):
111
+ def __init__(self) -> None: # noqa: D107
112
+ super().__init__(Path(os.devnull).parent, echo=False)
113
+
114
+ def emit(self, event: str, **fields: Any) -> dict[str, Any]: # noqa: D102
115
+ return {"event": event, **fields}
@@ -0,0 +1,169 @@
1
+ """Workload execution and heartbeat (spec sections 12, 13, 16).
2
+
3
+ The CLI registers the workload with the remote watchdog, keeps a heartbeat
4
+ alive while it runs, and records completion. If the heartbeat disappears
5
+ (CLI crash, SSH drop, laptop sleep), the watchdog lets the grace period
6
+ expire and the instance falls back to IDLE and the normal shutdown rules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from typing import Optional, Sequence
17
+
18
+ from .beacon import WatchdogClient
19
+ from .events import WORKLOAD_FINISHED, WORKLOAD_STARTED, EventLog
20
+ from .models import Execution, Workload
21
+ from .state import StateStore, WorkloadRecord, new_workload_id, utc_now
22
+
23
+
24
+ class Heartbeat:
25
+ """Background thread that keeps the remote watchdog informed."""
26
+
27
+ def __init__(
28
+ self,
29
+ workload_id: str,
30
+ interval: float,
31
+ store: StateStore,
32
+ beacon: Optional[WatchdogClient] = None,
33
+ ) -> None:
34
+ self.workload_id = workload_id
35
+ self.interval = max(5.0, float(interval))
36
+ self.store = store
37
+ self.beacon = beacon
38
+ self._stop = threading.Event()
39
+ self._thread: Optional[threading.Thread] = None
40
+ self.failures = 0
41
+
42
+ def _beat(self) -> None:
43
+ if self.beacon is not None:
44
+ if self.beacon.workload_heartbeat(self.workload_id) is None:
45
+ self.failures += 1
46
+ else:
47
+ self.failures = 0
48
+ try:
49
+ with self.store.transaction(timeout=10) as state:
50
+ record = state.workloads.get(self.workload_id)
51
+ if record is not None:
52
+ record.last_heartbeat = utc_now()
53
+ state.touch()
54
+ except Exception: # noqa: BLE001 - a heartbeat must never kill a workload
55
+ pass
56
+
57
+ def start(self) -> "Heartbeat":
58
+ self._beat()
59
+
60
+ def _loop() -> None:
61
+ while not self._stop.wait(self.interval):
62
+ self._beat()
63
+
64
+ self._thread = threading.Thread(target=_loop, name="jambu-heartbeat", daemon=True)
65
+ self._thread.start()
66
+ return self
67
+
68
+ def stop(self) -> None:
69
+ self._stop.set()
70
+ if self._thread is not None:
71
+ self._thread.join(timeout=2.0)
72
+
73
+ def __enter__(self) -> "Heartbeat":
74
+ return self.start()
75
+
76
+ def __exit__(self, *exc_info) -> None:
77
+ self.stop()
78
+
79
+
80
+ class ExecutionManager:
81
+ """Registers, runs and closes out workloads."""
82
+
83
+ def __init__(
84
+ self,
85
+ store: StateStore,
86
+ events: EventLog,
87
+ beacon: Optional[WatchdogClient] = None,
88
+ heartbeat_interval: float = 30.0,
89
+ ) -> None:
90
+ self.store = store
91
+ self.events = events
92
+ self.beacon = beacon
93
+ self.heartbeat_interval = heartbeat_interval
94
+
95
+ def register(self, command: Sequence[str], label: str = "") -> WorkloadRecord:
96
+ record = WorkloadRecord(
97
+ id=new_workload_id(),
98
+ label=label or " ".join(command)[:80],
99
+ command=list(command),
100
+ state="running",
101
+ pid=os.getpid(),
102
+ )
103
+ with self.store.transaction() as state:
104
+ state.workloads[record.id] = record
105
+ state.touch()
106
+ state.prune_workloads()
107
+ if self.beacon is not None:
108
+ self.beacon.workload_start(record.id, record.label)
109
+ self.events.emit(WORKLOAD_STARTED, workload_id=record.id, label=record.label)
110
+ return record
111
+
112
+ def finish(self, record: WorkloadRecord, returncode: Optional[int]) -> None:
113
+ state_name = "finished" if returncode == 0 else "failed"
114
+ with self.store.transaction() as state:
115
+ stored = state.workloads.get(record.id)
116
+ if stored is not None:
117
+ stored.state = state_name
118
+ stored.finished_at = utc_now()
119
+ stored.returncode = returncode
120
+ state.touch()
121
+ if self.beacon is not None:
122
+ self.beacon.workload_end(record.id, returncode, state_name)
123
+ self.events.emit(
124
+ WORKLOAD_FINISHED,
125
+ workload_id=record.id,
126
+ returncode=returncode,
127
+ state=state_name,
128
+ duration_seconds=round(utc_now() - record.started_at, 1),
129
+ )
130
+
131
+ def run_local(self, workload: Workload, record: WorkloadRecord) -> Execution:
132
+ """Run the command on the developer machine against the remote runtime."""
133
+ execution = Execution(
134
+ id=record.id, workload_id=record.id, started_at=time.time()
135
+ )
136
+ env = os.environ.copy()
137
+ env.update({k: str(v) for k, v in workload.env.items()})
138
+
139
+ process = subprocess.Popen( # noqa: S603 - the user's own command, by design
140
+ list(workload.command),
141
+ cwd=workload.cwd or None,
142
+ env=env,
143
+ )
144
+
145
+ def _forward(signum, _frame): # pragma: no cover - interactive path
146
+ try:
147
+ process.send_signal(signum)
148
+ except ProcessLookupError:
149
+ pass
150
+
151
+ previous = {}
152
+ for sig in (signal.SIGINT, signal.SIGTERM):
153
+ try:
154
+ previous[sig] = signal.signal(sig, _forward)
155
+ except ValueError:
156
+ pass
157
+ try:
158
+ returncode = process.wait()
159
+ finally:
160
+ for sig, handler in previous.items():
161
+ try:
162
+ signal.signal(sig, handler)
163
+ except ValueError:
164
+ pass
165
+
166
+ execution.finished_at = time.time()
167
+ execution.returncode = returncode
168
+ execution.state = "finished" if returncode == 0 else "failed"
169
+ return execution
@@ -0,0 +1,62 @@
1
+ """Model server readiness checks.
2
+
3
+ A passing health check means the runtime is *available*; per spec section 12
4
+ it is explicitly NOT workload activity and never refreshes the idle timer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Callable, Optional
11
+
12
+ import httpx
13
+
14
+ from .errors import RuntimeStartupError
15
+
16
+
17
+ def probe(url: str, timeout: float = 5.0) -> tuple[bool, str]:
18
+ try:
19
+ response = httpx.get(url, timeout=timeout)
20
+ except httpx.HTTPError as exc:
21
+ return False, type(exc).__name__
22
+ if response.status_code < 400:
23
+ return True, f"{response.status_code}"
24
+ return False, f"HTTP {response.status_code}"
25
+
26
+
27
+ def wait_for_healthy(
28
+ url: str,
29
+ *,
30
+ timeout: float,
31
+ interval: float = 10.0,
32
+ on_attempt: Optional[Callable[[int, float, str], None]] = None,
33
+ should_abort: Optional[Callable[[], Optional[str]]] = None,
34
+ ) -> None:
35
+ """Poll ``url`` until it answers or ``timeout`` elapses.
36
+
37
+ ``should_abort`` lets the caller fail fast when the instance itself died
38
+ instead of waiting out the full startup timeout.
39
+ """
40
+ deadline = time.time() + timeout
41
+ attempt = 0
42
+ last_detail = "not started"
43
+ interval = max(2.0, min(interval, 30.0))
44
+
45
+ while time.time() < deadline:
46
+ attempt += 1
47
+ if should_abort is not None:
48
+ reason = should_abort()
49
+ if reason:
50
+ raise RuntimeStartupError(f"model server did not start: {reason}")
51
+ ok, last_detail = probe(url)
52
+ if ok:
53
+ return
54
+ if on_attempt is not None:
55
+ on_attempt(attempt, max(0.0, deadline - time.time()), last_detail)
56
+ time.sleep(interval)
57
+
58
+ raise RuntimeStartupError(
59
+ f"model server did not become healthy within {int(timeout)}s "
60
+ f"({url}, last probe: {last_detail}). "
61
+ "Check `gpu logs` - model downloads can be slow on first boot."
62
+ )