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
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JambuError(Exception):
|
|
13
|
+
"""Base class for every error raised by the CLI."""
|
|
14
|
+
|
|
15
|
+
exit_code = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(JambuError):
|
|
19
|
+
"""jambu.yaml (or legacy config.yml) is missing, malformed or semantically invalid."""
|
|
20
|
+
|
|
21
|
+
exit_code = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StateError(JambuError):
|
|
25
|
+
"""Local runtime state is missing or inconsistent."""
|
|
26
|
+
|
|
27
|
+
exit_code = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LockBusy(StateError):
|
|
31
|
+
"""Another gpu command (usually a setup, for minutes) holds this project's lock."""
|
|
32
|
+
|
|
33
|
+
exit_code = 12
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProviderError(JambuError):
|
|
37
|
+
"""Base class for every provider-originated failure."""
|
|
38
|
+
|
|
39
|
+
exit_code = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationError(ProviderError):
|
|
43
|
+
exit_code = 5
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CapacityUnavailableError(ProviderError):
|
|
47
|
+
exit_code = 6
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ProvisioningError(ProviderError):
|
|
51
|
+
exit_code = 7
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ProviderTimeoutError(ProviderError):
|
|
55
|
+
exit_code = 8
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnsupportedCapabilityError(ProviderError):
|
|
59
|
+
exit_code = 9
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BudgetExceededError(JambuError):
|
|
63
|
+
exit_code = 10
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RuntimeStartupError(JambuError):
|
|
67
|
+
exit_code = 11
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HostFailure(RuntimeStartupError):
|
|
71
|
+
"""The machine failed, not the config: another host may well succeed.
|
|
72
|
+
|
|
73
|
+
Raised while an instance boots, before its container runs: the provider
|
|
74
|
+
reports it failed (an image pull that never finished, a registry the host
|
|
75
|
+
cannot reach), it vanished, or it never reached running within
|
|
76
|
+
``health.boot_timeout``. A model server that dies once the container runs
|
|
77
|
+
is a plain RuntimeStartupError - the same config would fail anywhere.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str, reason: str = "", instance: Any = None) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
# Short cause for one-line output, e.g. "image pull timed out after 15m".
|
|
83
|
+
self.reason = reason or message
|
|
84
|
+
# The models.Instance that failed, so its machine can be excluded.
|
|
85
|
+
self.instance = instance
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WorkloadFailed(JambuError):
|
|
89
|
+
"""The user workload exited non-zero. Carries the original exit code."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, message: str, returncode: int) -> None:
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.returncode = returncode
|
|
94
|
+
self.exit_code = returncode or 1
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Structured lifecycle events (spec section 24).
|
|
2
|
+
|
|
3
|
+
Every lifecycle action appends one JSON object to ``.gpu/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
|
+
# A machine failed to boot; setup rents another one.
|
|
38
|
+
HOST_FAILED = "host_failed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EventLog:
|
|
42
|
+
"""Append-only JSONL sink. Never raises into the caller's path."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, logs_dir: Path, echo: bool = False) -> None:
|
|
45
|
+
self.dir = Path(logs_dir)
|
|
46
|
+
self.path = self.dir / "events.jsonl"
|
|
47
|
+
self.echo = echo
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._context: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
def bind(self, **context: Any) -> "EventLog":
|
|
52
|
+
self._context.update({k: v for k, v in context.items() if v is not None})
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def emit(self, event: str, **fields: Any) -> dict[str, Any]:
|
|
56
|
+
record: dict[str, Any] = {
|
|
57
|
+
"ts": to_iso(utc_now()),
|
|
58
|
+
"event": event,
|
|
59
|
+
"pid": os.getpid(),
|
|
60
|
+
}
|
|
61
|
+
record.update(self._context)
|
|
62
|
+
record.update({k: v for k, v in fields.items() if v is not None})
|
|
63
|
+
line = json.dumps(record, default=str)
|
|
64
|
+
with self._lock:
|
|
65
|
+
try:
|
|
66
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
with open(self.path, "a") as handle:
|
|
68
|
+
handle.write(line + "\n")
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
if self.echo:
|
|
72
|
+
print(line, file=sys.stderr)
|
|
73
|
+
return record
|
|
74
|
+
|
|
75
|
+
def tail(self, limit: int = 50, event: Optional[str] = None) -> list[dict[str, Any]]:
|
|
76
|
+
if not self.path.is_file():
|
|
77
|
+
return []
|
|
78
|
+
out: list[dict[str, Any]] = []
|
|
79
|
+
for line in self.path.read_text().splitlines():
|
|
80
|
+
line = line.strip()
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
record = json.loads(line)
|
|
85
|
+
except json.JSONDecodeError:
|
|
86
|
+
continue
|
|
87
|
+
if event and record.get("event") != event:
|
|
88
|
+
continue
|
|
89
|
+
out.append(record)
|
|
90
|
+
return out[-limit:]
|
|
91
|
+
|
|
92
|
+
def extend(self, records: Iterable[dict[str, Any]]) -> int:
|
|
93
|
+
"""Merge records produced elsewhere (e.g. pulled from the remote watchdog)."""
|
|
94
|
+
count = 0
|
|
95
|
+
seen = {(r.get("ts"), r.get("event")) for r in self.tail(limit=2000)}
|
|
96
|
+
with self._lock:
|
|
97
|
+
try:
|
|
98
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
with open(self.path, "a") as handle:
|
|
100
|
+
for record in records:
|
|
101
|
+
key = (record.get("ts"), record.get("event"))
|
|
102
|
+
if key in seen:
|
|
103
|
+
continue
|
|
104
|
+
seen.add(key)
|
|
105
|
+
handle.write(json.dumps(record, default=str) + "\n")
|
|
106
|
+
count += 1
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return count
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NullEventLog(EventLog):
|
|
113
|
+
def __init__(self) -> None: # noqa: D107
|
|
114
|
+
super().__init__(Path(os.devnull).parent, echo=False)
|
|
115
|
+
|
|
116
|
+
def emit(self, event: str, **fields: Any) -> dict[str, Any]: # noqa: D102
|
|
117
|
+
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
|
+
)
|
gpu4agents/core/hosts.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Machines that recently failed us, remembered across setups.
|
|
2
|
+
|
|
3
|
+
A host that could not pull the image or reach the network fails the next
|
|
4
|
+
setup the same way, and the cheapest-offer search keeps picking it. Each
|
|
5
|
+
failure is kept for ``BAD_HOST_TTL_S`` in ``$GPU_HOME/bad_hosts.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .sweep import jambu_home
|
|
16
|
+
|
|
17
|
+
BAD_HOST_TTL_S = 24 * 3600.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BadHosts:
|
|
21
|
+
def __init__(self, path: Optional[Path] = None, ttl_s: float = BAD_HOST_TTL_S) -> None:
|
|
22
|
+
self.path = Path(path) if path else jambu_home() / "bad_hosts.json"
|
|
23
|
+
self.ttl_s = ttl_s
|
|
24
|
+
|
|
25
|
+
def _load(self) -> dict:
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(self.path.read_text() or "{}")
|
|
28
|
+
except (OSError, ValueError):
|
|
29
|
+
# Unreadable memory only costs a retry; never block a setup on it.
|
|
30
|
+
return {}
|
|
31
|
+
return data if isinstance(data, dict) else {}
|
|
32
|
+
|
|
33
|
+
def recent(self, provider: str, now: Optional[float] = None) -> list[str]:
|
|
34
|
+
now = now if now is not None else time.time()
|
|
35
|
+
hosts = self._load().get(provider) or {}
|
|
36
|
+
return sorted(
|
|
37
|
+
host
|
|
38
|
+
for host, entry in hosts.items()
|
|
39
|
+
if isinstance(entry, dict) and now - float(entry.get("at") or 0) < self.ttl_s
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def mark(self, provider: str, host_id: str, reason: str, now: Optional[float] = None) -> None:
|
|
43
|
+
now = now if now is not None else time.time()
|
|
44
|
+
data = self._load()
|
|
45
|
+
hosts = data.setdefault(provider, {})
|
|
46
|
+
hosts[str(host_id)] = {"at": now, "reason": reason[:200]}
|
|
47
|
+
# Drop expired entries so the file stays small.
|
|
48
|
+
data[provider] = {
|
|
49
|
+
h: e for h, e in hosts.items()
|
|
50
|
+
if isinstance(e, dict) and now - float(e.get("at") or 0) < self.ttl_s
|
|
51
|
+
}
|
|
52
|
+
try:
|
|
53
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tmp = self.path.with_suffix(".tmp")
|
|
55
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
56
|
+
tmp.replace(self.path)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|