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.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
jambu_gpu/core/errors.py
ADDED
|
@@ -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
|
jambu_gpu/core/events.py
ADDED
|
@@ -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
|
jambu_gpu/core/health.py
ADDED
|
@@ -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
|
+
)
|