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,1335 @@
1
+ """Orchestration engine.
2
+
3
+ Owns the flows in spec sections 15-18 and 23: provisioning, run, status,
4
+ reconciliation, idempotency and cleanup. It knows about the provider
5
+ *contract* and about capabilities - never about a specific provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import secrets
12
+ import time
13
+ from typing import Any, Callable, Optional, Sequence
14
+
15
+ from .. import watchdog as watchdog_pkg
16
+ from ..providers.base import GPUProvider
17
+ from ..providers.registry import load_builtin_providers
18
+ from ..runtime import runtime_registry
19
+ from ..runtime.base import ENDPOINT_NAME, ModelRuntime, image_tag, max_cuda
20
+ from ..watchdog.builder import build_agent_config, build_agent_source, build_onstart_script
21
+ from . import events as ev
22
+ from . import policy
23
+ from .beacon import WatchdogClient, WatchdogUnavailable
24
+ from .config import RuntimeConfig
25
+ from .credentials import CredentialResolver
26
+ from .durations import format_duration
27
+ from .errors import (
28
+ BudgetExceededError,
29
+ ConfigError,
30
+ HostFailure,
31
+ JambuError,
32
+ LockBusy,
33
+ ProviderError,
34
+ RuntimeStartupError,
35
+ StateError,
36
+ UnsupportedCapabilityError,
37
+ WorkloadFailed,
38
+ )
39
+ from .events import EventLog
40
+ from .execution import ExecutionManager, Heartbeat
41
+ from .health import wait_for_healthy
42
+ from .models import (
43
+ ComputeSpec,
44
+ GpuSpec,
45
+ Instance,
46
+ InstanceState,
47
+ JAMBU_LABEL_PREFIX,
48
+ ValidationResult,
49
+ Workload,
50
+ )
51
+ from .hosts import BadHosts
52
+ from .state import RuntimeState, StateStore, WatchdogInfo, utc_now
53
+
54
+ Echo = Callable[[str], None]
55
+
56
+ # Signatures, in a dead model server's log, of a network that failed it - not
57
+ # of a model or config that would fail on any machine.
58
+ _HOST_NETWORK_FAILURE = re.compile(
59
+ r"RemoteProtocolError|ConnectError|ConnectTimeout|ReadTimeout|"
60
+ r"Connection (?:reset|refused|aborted)|TLS handshake timeout|"
61
+ r"Temporary failure in name resolution|Network is unreachable"
62
+ )
63
+
64
+ # A pull stuck retrying a layer: its countdown changes, but it is no progress.
65
+ _PULL_RETRY = re.compile(r"\bretrying\b", re.IGNORECASE)
66
+
67
+ # Where `gpu logs --source` reads from: the watchdog serves the instance files.
68
+ LOG_SOURCES = ("runtime", "onstart", "watchdog", "provider")
69
+ # Runtime log lines quoted in the error when the model server dies at boot.
70
+ STARTUP_FAILURE_LOG_LINES = 30
71
+ # How long read-only commands wait for a busy lock before reporting the last
72
+ # saved state instead: a setup holds it for the whole boot, often 10+ minutes.
73
+ READ_LOCK_WAIT_S = 3.0
74
+
75
+
76
+ def _noop(_message: str) -> None:
77
+ return None
78
+
79
+
80
+ def _pull_progress(message: Optional[str]) -> Optional[str]:
81
+ """The provider's boot message when it shows the pull moving, else None.
82
+
83
+ Vast reports the image pull's latest line (``<layer>: Download complete``,
84
+ ``<layer>: Pull complete``). A new line means a layer moved; the same line,
85
+ no line, or a retry countdown means it did not.
86
+ """
87
+ text = (message or "").strip()
88
+ if not text or _PULL_RETRY.search(text):
89
+ return None
90
+ return text
91
+
92
+
93
+ class Engine:
94
+ def __init__(
95
+ self,
96
+ config: RuntimeConfig,
97
+ provider: Optional[GPUProvider] = None,
98
+ echo: Optional[Echo] = None,
99
+ credentials: Optional[CredentialResolver] = None,
100
+ ) -> None:
101
+ self.config = config
102
+ self.echo = echo or _noop
103
+ self.credentials = credentials or CredentialResolver(
104
+ config.project_dir, profile=config.provider.profile
105
+ )
106
+ registry = load_builtin_providers()
107
+ self.provider = provider or registry.get(config, self.credentials)
108
+ self.runtime: ModelRuntime = runtime_registry.get(config)
109
+ self.runtime.provider_image = self.provider.preferred_images.get(self.runtime.engine)
110
+ self.store = StateStore(config.state_dir)
111
+ self.events = EventLog(self.store.logs_dir).bind(provider=self.provider.name)
112
+ # Whether the last reconcile read the watchdog's state. Traffic activity
113
+ # lives only on the instance, so without it the local idle clock lies.
114
+ self.watchdog_read = False
115
+
116
+ # ------------------------------------------------------------------
117
+ # policy plumbing
118
+ # ------------------------------------------------------------------
119
+
120
+ def policy_config(self, hourly_cost_usd: Optional[float] = None) -> policy.PolicyConfig:
121
+ caps = self.provider.capabilities
122
+ cfg = self.config
123
+ return policy.PolicyConfig(
124
+ idle_timeout_s=cfg.idle_timeout_s,
125
+ max_lifetime_s=cfg.max_lifetime_s,
126
+ heartbeat_grace_s=cfg.heartbeat_grace_s,
127
+ auto_stop=cfg.lifecycle.auto_stop,
128
+ auto_destroy=cfg.lifecycle.auto_destroy,
129
+ allow_indefinite_lock=cfg.lifecycle.allow_indefinite_lock,
130
+ stop_supported=caps.stop,
131
+ destroy_supported=caps.destroy,
132
+ stop_eliminates_billing=caps.stop_eliminates_billing,
133
+ hourly_cost_usd=hourly_cost_usd,
134
+ max_session_cost_usd=cfg.budget.max_session_cost_usd,
135
+ stopped_ttl_s=cfg.stopped_ttl_s,
136
+ )
137
+
138
+ def snapshot(self, state: RuntimeState) -> policy.Snapshot:
139
+ now = utc_now()
140
+ return policy.Snapshot(
141
+ now=now,
142
+ created_at=state.created_at or now,
143
+ last_activity=state.last_activity or state.created_at or now,
144
+ workloads=[w.to_policy() for w in state.workloads.values()],
145
+ starting=state.starting,
146
+ locked=state.locked,
147
+ locked_until=state.locked_until,
148
+ provider_state=state.state,
149
+ session_started_at=state.session_started_at,
150
+ stopped_since=state.stopped_since,
151
+ )
152
+
153
+ def _idle_blind(self, state: RuntimeState) -> bool:
154
+ """True when only the unreachable watchdog knows about recent activity.
155
+
156
+ Inference traffic is counted on the instance; the local last_activity
157
+ never sees it. Judging idleness from it would stop a serving endpoint,
158
+ so the CLI leaves the idle rule to the watchdog. max_lifetime, the
159
+ budget and stopped_ttl still apply.
160
+ """
161
+ return (
162
+ self.config.lifecycle.watchdog.enabled
163
+ and state.watchdog.installed
164
+ and not self.watchdog_read
165
+ # A stopped machine has no watchdog to reach and no idle to judge.
166
+ and InstanceState(state.state).is_billable_compute
167
+ )
168
+
169
+ def beacon(self, state: RuntimeState) -> Optional[WatchdogClient]:
170
+ info = state.watchdog
171
+ if not info.installed or not info.url:
172
+ return None
173
+ return WatchdogClient(info.url, info.token)
174
+
175
+ # ------------------------------------------------------------------
176
+ # validate (spec section 15, steps 1-5)
177
+ # ------------------------------------------------------------------
178
+
179
+ def validate(self, check_provider: bool = True) -> ValidationResult:
180
+ result = ValidationResult()
181
+ cfg = self.config
182
+
183
+ if cfg.lifecycle.idle_timeout is None and cfg.lifecycle.max_lifetime is None:
184
+ result.error(
185
+ "lifecycle needs at least one of idle_timeout or max_lifetime; "
186
+ "without a bound an abandoned GPU bills forever",
187
+ "lifecycle",
188
+ )
189
+ if not cfg.lifecycle.auto_stop and not cfg.lifecycle.auto_destroy:
190
+ result.warn(
191
+ "auto_stop and auto_destroy are both false: the guard will report "
192
+ "but never shut anything down",
193
+ "lifecycle",
194
+ )
195
+ if not cfg.lifecycle.watchdog.enabled:
196
+ result.warn(
197
+ "lifecycle.watchdog.enabled is false: shutdown depends on this CLI "
198
+ "staying alive (spec section 14)",
199
+ "lifecycle.watchdog",
200
+ )
201
+ if cfg.lifecycle.allow_indefinite_lock:
202
+ result.warn(
203
+ "allow_indefinite_lock is true: a forgotten lock defeats cost protection",
204
+ "lifecycle.allow_indefinite_lock",
205
+ )
206
+ if cfg.budget.max_hourly_cost_usd is None:
207
+ result.warn("no budget.max_hourly_cost_usd set", "budget")
208
+ if (
209
+ not self.provider.capabilities.stop_eliminates_billing
210
+ and cfg.stopped_ttl_s is None
211
+ and not cfg.lifecycle.auto_destroy
212
+ ):
213
+ result.warn(
214
+ f"a stopped {self.provider.display_name} instance still bills storage, and "
215
+ "with lifecycle.stopped_ttl disabled and auto_destroy false nothing ever "
216
+ "removes it; set lifecycle.stopped_ttl (e.g. 24h)",
217
+ "lifecycle.stopped_ttl",
218
+ )
219
+
220
+ result.merge(self.runtime.validate())
221
+ if cfg.runtime.engine != "none" and image_tag(self.runtime.image()) == "latest":
222
+ result.warn(
223
+ f"unpinned image '{self.runtime.image()}': the CUDA it needs can change "
224
+ "under you (pin a tag such as "
225
+ f"'{type(self.runtime).default_image}')",
226
+ "compute.image",
227
+ )
228
+
229
+ if cfg.model.hf_token_env and not self.credentials.get(cfg.model.hf_token_env):
230
+ result.warn(
231
+ f"{cfg.model.hf_token_env} is not set; gated models will fail to download",
232
+ "model.hf_token_env",
233
+ )
234
+
235
+ if check_provider:
236
+ try:
237
+ # The provider previews offers from config alone; hand it the
238
+ # CUDA floor the runtime image adds, so validate sees what setup will.
239
+ checked = cfg
240
+ if self.cuda_min() != cfg.compute.gpu.cuda_min:
241
+ checked = cfg.model_copy(deep=True)
242
+ checked.compute.gpu.cuda_min = self.cuda_min()
243
+ result.merge(self.provider.validate(checked))
244
+ except JambuError as exc:
245
+ result.error(str(exc), "provider")
246
+ return result
247
+
248
+ # ------------------------------------------------------------------
249
+ # reconciliation (spec section 18)
250
+ # ------------------------------------------------------------------
251
+
252
+ def reconcile(self, state: Optional[RuntimeState] = None) -> RuntimeState:
253
+ """Merge local state with provider truth. Repairs local state in place."""
254
+ owns_lock = state is None
255
+ if owns_lock:
256
+ with self.store.process_lock():
257
+ state = self.store.load()
258
+ reconciled = self._reconcile(state)
259
+ self.store.save(reconciled)
260
+ return reconciled
261
+ return self._reconcile(state)
262
+
263
+ def _reconcile(self, state: RuntimeState) -> RuntimeState:
264
+ self.watchdog_read = False
265
+ if not state.instance_id:
266
+ state.state = InstanceState.DESTROYED.value
267
+ return state
268
+
269
+ instance = state.instance or Instance(id=state.instance_id, provider=self.provider.name)
270
+ try:
271
+ status = self.provider.status(instance)
272
+ except ProviderError as exc:
273
+ self.events.emit(ev.PROVIDER_ERROR, operation="status", error=str(exc))
274
+ return state # keep local state; the API blip is not authoritative
275
+
276
+ previous = state.state
277
+ if not status.exists:
278
+ state.clear_instance(InstanceState.DESTROYED.value)
279
+ self.events.emit(
280
+ ev.STATE_RECONCILED,
281
+ previous_state=previous,
282
+ state=InstanceState.DESTROYED.value,
283
+ reason="instance missing at provider",
284
+ )
285
+ return state
286
+
287
+ current = status.instance or instance
288
+ state.instance = current
289
+ state.instance_id = current.id
290
+ state.hourly_cost_usd = status.hourly_cost_usd or state.hourly_cost_usd
291
+ if current.created_at:
292
+ state.created_at = current.created_at
293
+ endpoint = current.endpoint_url(ENDPOINT_NAME)
294
+ if endpoint:
295
+ state.endpoint = endpoint
296
+
297
+ watchdog_url = current.endpoint_url("watchdog")
298
+ if watchdog_url and state.watchdog.token:
299
+ state.watchdog.url = watchdog_url
300
+ state.watchdog.installed = True
301
+
302
+ # The watchdog, when reachable, is the authority on activity.
303
+ remote = None
304
+ remote_terminated = False
305
+ client = self.beacon(state)
306
+ if client is not None:
307
+ remote = client.try_state()
308
+ if remote:
309
+ self.watchdog_read = True
310
+ state.watchdog.last_contact = utc_now()
311
+ state.watchdog.last_error = None
312
+ state.locked = bool(remote.get("locked"))
313
+ state.locked_until = remote.get("locked_until")
314
+ state.starting = bool(remote.get("starting"))
315
+ if remote.get("last_activity"):
316
+ state.last_activity = max(
317
+ state.last_activity or 0.0, float(remote["last_activity"])
318
+ )
319
+ # `terminated` is the watchdog's memory of having shut the machine
320
+ # down. It survives a restart, so it is only a hint for when the
321
+ # provider itself cannot say whether the instance runs.
322
+ remote_terminated = bool(remote.get("terminated"))
323
+ pulled = client.events(limit=200)
324
+ if pulled:
325
+ self.events.extend(pulled)
326
+ elif state.watchdog.installed:
327
+ state.watchdog.last_error = "unreachable"
328
+
329
+ if status.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
330
+ # Provider truth first: a previous local STOPPED must not leak
331
+ # into the derivation after the instance was restarted.
332
+ state.state = status.state.value
333
+ derived = policy.derive_state(self.snapshot(state), self.policy_config())
334
+ state.state = derived if derived != InstanceState.UNKNOWN.value else status.state.value
335
+ else:
336
+ if status.state is InstanceState.UNKNOWN and remote_terminated:
337
+ state.state = InstanceState.STOPPED.value
338
+ else:
339
+ state.state = status.state.value
340
+ if status.state in (InstanceState.STOPPED, InstanceState.FAILED):
341
+ for record in state.workloads.values():
342
+ if record.state in ("running", "queued"):
343
+ record.state = "failed"
344
+ record.finished_at = utc_now()
345
+
346
+ # Track actual billable time, not wall-clock time since creation - a
347
+ # `status` checked long after auto-stop must not keep inflating cost.
348
+ billable = InstanceState(state.state).is_billable_compute
349
+ state.mark_billable(billable)
350
+ if state.state == InstanceState.STOPPED.value:
351
+ state.mark_stopped(ran_at=[current.created_at or None])
352
+ elif billable and state.stopped_since is not None:
353
+ # Restarted outside this CLI (web console, another machine): a new
354
+ # session begins now.
355
+ state.mark_session_start()
356
+ elif billable and state.session_started_at is None:
357
+ # A state file from before sessions existed: never grant it a
358
+ # fresh max_lifetime, count from creation.
359
+ state.session_started_at = state.created_at
360
+ if state.hourly_cost_usd:
361
+ state.session_cost_usd = round(
362
+ state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
363
+ )
364
+
365
+ if previous != state.state:
366
+ self.events.emit(
367
+ ev.STATE_RECONCILED, previous_state=previous, state=state.state
368
+ )
369
+ return state
370
+
371
+ # ------------------------------------------------------------------
372
+ # setup (spec section 15)
373
+ # ------------------------------------------------------------------
374
+
375
+ def build_spec(self, onstart: str = "") -> ComputeSpec:
376
+ cfg = self.config
377
+ env = dict(self.runtime.env())
378
+ hf_token = (
379
+ self.credentials.get(cfg.model.hf_token_env) if cfg.model.hf_token_env else None
380
+ )
381
+ if hf_token:
382
+ env["HF_TOKEN"] = hf_token
383
+ env["HUGGING_FACE_HUB_TOKEN"] = hf_token
384
+ return ComputeSpec(
385
+ gpu=GpuSpec(
386
+ min_vram_gb=cfg.compute.gpu.min_vram_gb,
387
+ count=cfg.compute.gpu.count,
388
+ name_filter=cfg.compute.gpu.name_filter,
389
+ cuda_min=self.cuda_min(),
390
+ compute_cap_min=self.compute_cap_min(),
391
+ ),
392
+ disk_gb=cfg.compute.disk_gb,
393
+ image=self.runtime.image(),
394
+ ports=self._ports(),
395
+ env=env,
396
+ onstart=onstart,
397
+ label=f"{JAMBU_LABEL_PREFIX}{cfg.model.id.split('/')[-1][:24]}-{cfg.fingerprint()[:6]}",
398
+ spot=cfg.compute.spot,
399
+ region=cfg.compute.region,
400
+ max_hourly_cost_usd=cfg.budget.max_hourly_cost_usd,
401
+ provider_options=dict(cfg.provider.options),
402
+ )
403
+
404
+ def cuda_min(self) -> Optional[str]:
405
+ """The stricter of compute.gpu.cuda_min and what the runtime image needs."""
406
+ configured = self.config.compute.gpu.cuda_min
407
+ needed = max_cuda(configured, self.runtime.required_cuda())
408
+ if needed is None:
409
+ return configured
410
+ if configured is not None and max_cuda(configured) == needed:
411
+ return configured
412
+ return f"{needed:.1f}"
413
+
414
+ def compute_cap_min(self) -> Optional[float]:
415
+ """The stricter of compute.gpu.compute_cap_min and the runtime's floor."""
416
+ candidates = [
417
+ c
418
+ for c in (
419
+ self.config.compute.gpu.compute_cap_min,
420
+ self.runtime.required_compute_cap(),
421
+ )
422
+ if c is not None
423
+ ]
424
+ return max(candidates) if candidates else None
425
+
426
+ def _ports(self) -> list[int]:
427
+ ports = list(self.runtime.ports())
428
+ if self.config.lifecycle.watchdog.enabled:
429
+ ports.append(self.config.lifecycle.watchdog.port)
430
+ return ports
431
+
432
+ def _api_key(self) -> Optional[str]:
433
+ """The key the model server will demand: the configured one, or a fresh one.
434
+
435
+ The endpoint is public, and traffic counts as activity: an open one
436
+ would let anyone use the GPU and keep it billing.
437
+ """
438
+ if self.config.runtime.api_key:
439
+ return self.config.runtime.api_key
440
+ if self.runtime.api_key_env is None:
441
+ return None
442
+ return secrets.token_urlsafe(32)
443
+
444
+ def _build_onstart(self, token: str, api_key: Optional[str] = None) -> str:
445
+ cfg = self.config
446
+ enabled = cfg.lifecycle.watchdog.enabled
447
+ spec = self.provider.watchdog_spec(None) if enabled else None
448
+ if enabled and spec is None:
449
+ raise UnsupportedCapabilityError(
450
+ f"provider '{self.provider.name}' supplies no watchdog actions; "
451
+ "set lifecycle.watchdog.enabled: false to proceed without remote "
452
+ "lifecycle enforcement"
453
+ )
454
+ agent_source = build_agent_source(spec.actions_source if spec else None)
455
+ probe = (
456
+ self.runtime.activity_probe() if cfg.lifecycle.traffic_counts_as_activity else None
457
+ )
458
+ agent_config = build_agent_config(
459
+ provider=self.provider.name,
460
+ instance_id="", # resolved by /identify or the provider's own env
461
+ token=token,
462
+ port=cfg.lifecycle.watchdog.port,
463
+ interval_s=cfg.watchdog_interval_s,
464
+ # The watchdog starts with the container, but the CLI's waits span
465
+ # the whole boot: never let the remote guard cut a legitimate one.
466
+ startup_grace_s=cfg.boot_timeout_s + cfg.startup_timeout_s + 300.0,
467
+ policy=self.policy_config(),
468
+ created_at=utc_now(),
469
+ watchdog_spec=spec,
470
+ activity_probe=probe,
471
+ runtime_pid_path=self.runtime.runtime_pid_path(),
472
+ runtime_log_path=self.runtime.runtime_log_path(),
473
+ )
474
+ runtime_env = dict(self.runtime.env())
475
+ # A configured key is already on the command line; only a generated
476
+ # one travels through the environment.
477
+ if api_key and api_key != cfg.runtime.api_key and self.runtime.api_key_env:
478
+ runtime_env[self.runtime.api_key_env] = api_key
479
+ return build_onstart_script(
480
+ agent_source=agent_source,
481
+ agent_config=agent_config,
482
+ runtime_start_command=self.runtime.start_command(),
483
+ runtime_env=runtime_env,
484
+ watchdog_enabled=enabled,
485
+ )
486
+
487
+ def _check_budget(self, spec: ComputeSpec, allow_override: bool) -> Optional[float]:
488
+ ceiling = self.config.budget.max_hourly_cost_usd
489
+ if not self.provider.capabilities.pricing_query:
490
+ if ceiling and not allow_override:
491
+ self.echo(
492
+ "! provider cannot query pricing; budget.max_hourly_cost_usd "
493
+ "cannot be enforced before provisioning"
494
+ )
495
+ return None
496
+ offers = self.provider.find_offers(spec, limit=5)
497
+ if not offers:
498
+ return None
499
+ cheapest = offers[0]
500
+ if ceiling and cheapest.hourly_cost_usd > float(ceiling):
501
+ if not allow_override:
502
+ self.events.emit(
503
+ ev.BUDGET_REJECTED,
504
+ cheapest_hourly_cost_usd=cheapest.hourly_cost_usd,
505
+ max_hourly_cost_usd=float(ceiling),
506
+ )
507
+ raise BudgetExceededError(
508
+ f"cheapest matching offer is ${cheapest.hourly_cost_usd:.3f}/h, above "
509
+ f"budget.max_hourly_cost_usd (${float(ceiling):.3f}/h). "
510
+ "Re-run with --allow-cost-override to proceed anyway."
511
+ )
512
+ self.echo(
513
+ f"! cost override: ${cheapest.hourly_cost_usd:.3f}/h exceeds the "
514
+ f"${float(ceiling):.3f}/h budget"
515
+ )
516
+ return cheapest.hourly_cost_usd
517
+
518
+ def setup(
519
+ self,
520
+ *,
521
+ recreate: bool = False,
522
+ allow_cost_override: bool = False,
523
+ wait: bool = True,
524
+ ) -> RuntimeState:
525
+ cfg = self.config
526
+ fingerprint = cfg.fingerprint()
527
+
528
+ with self.store.process_lock():
529
+ state = self._reconcile(self.store.load())
530
+
531
+ # -- idempotency (spec section 23) -----------------------------
532
+ if state.has_instance():
533
+ if state.fingerprint != fingerprint and not recreate:
534
+ raise ConfigError(
535
+ f"instance {state.instance_id} was provisioned for a different "
536
+ "runtime (jambu.yaml changed since setup). Re-run with "
537
+ "`--recreate` to replace it, or `gpu destroy` first."
538
+ )
539
+ if not recreate:
540
+ self.echo(f"= reusing instance {state.instance_id} ({state.state})")
541
+ self.store.save(state)
542
+ return self.ensure_ready(state=state) if wait else state
543
+ self.echo(f"~ replacing instance {state.instance_id}")
544
+ self._terminate(state, action=policy.ACTION_DESTROY, reason="recreate")
545
+
546
+ # -- provision ------------------------------------------------
547
+ token = watchdog_pkg.generate_token()
548
+ api_key = self._api_key()
549
+ onstart = self._build_onstart(token, api_key)
550
+
551
+ self.events.emit(
552
+ ev.PROVISION_REQUESTED,
553
+ model=cfg.model.id,
554
+ engine=cfg.runtime.engine,
555
+ gpu_count=cfg.compute.gpu.count,
556
+ min_vram_gb=cfg.compute.gpu.min_vram_gb,
557
+ fingerprint=fingerprint,
558
+ )
559
+ self.echo(
560
+ f"> searching {self.provider.display_name} for {cfg.compute.gpu.count}x "
561
+ f">={cfg.compute.gpu.min_vram_gb}GB GPU, {cfg.compute.disk_gb}GB disk"
562
+ )
563
+
564
+ # On a GPU marketplace a bad host is normal: one that cannot pull
565
+ # the image, or reach the registry at all, costs one attempt and
566
+ # its machine is not rented again by this setup. Retrying means
567
+ # destroying the bad one first, so it needs cleanup_on_setup_failure.
568
+ attempts = cfg.compute.host_attempts if cfg.lifecycle.cleanup_on_setup_failure else 1
569
+ bad_hosts = BadHosts()
570
+ excluded: list[str] = bad_hosts.recent(self.provider.name)
571
+ for attempt in range(1, attempts + 1):
572
+ spec = self.build_spec(onstart)
573
+ spec.exclude_hosts = list(excluded)
574
+ # Every new offer is held to the budget, not just the first.
575
+ estimated = self._check_budget(spec, allow_cost_override)
576
+ if estimated:
577
+ self.echo(f" cheapest match: ${estimated:.3f}/h")
578
+ try:
579
+ return self._provision(state, spec, token, fingerprint, wait, api_key)
580
+ except HostFailure as exc:
581
+ retry = attempt < attempts
582
+ self._host_failed(state, exc, attempt, attempts, retry)
583
+ if retry and self._discard_host(state, exc, excluded, bad_hosts):
584
+ continue
585
+ if exc.instance is not None and exc.instance.host_id:
586
+ # The last host failed as surely as the others: the
587
+ # next setup must not walk straight back onto it.
588
+ bad_hosts.mark(self.provider.name, exc.instance.host_id, exc.reason)
589
+ if retry or attempts == 1:
590
+ self._fail_setup(state, exc)
591
+ raise
592
+ final = HostFailure(
593
+ f"{attempts} machines in a row failed to boot, giving up "
594
+ f"(compute.host_attempts). Last: {exc}",
595
+ reason=exc.reason,
596
+ instance=exc.instance,
597
+ )
598
+ self._fail_setup(state, final)
599
+ raise final from exc
600
+ except (JambuError, KeyboardInterrupt) as exc:
601
+ # Ctrl-C included: nobody is left to use the machine this
602
+ # setup was renting, so it must not stay booting on the bill.
603
+ self._fail_setup(state, exc)
604
+ raise
605
+ raise AssertionError("unreachable: the last attempt returns or raises")
606
+
607
+ def _provision(
608
+ self,
609
+ state: RuntimeState,
610
+ spec: ComputeSpec,
611
+ token: str,
612
+ fingerprint: str,
613
+ wait: bool,
614
+ api_key: Optional[str] = None,
615
+ ) -> RuntimeState:
616
+ cfg = self.config
617
+ instance = self.provider.setup(spec)
618
+ self.echo(
619
+ f"+ instance {instance.id} created "
620
+ f"({instance.gpu_count or cfg.compute.gpu.count}x "
621
+ f"{instance.gpu_name or 'GPU'}"
622
+ + (
623
+ f", ${instance.hourly_cost_usd:.3f}/h)"
624
+ if instance.hourly_cost_usd
625
+ else ")"
626
+ )
627
+ )
628
+
629
+ state.provider = self.provider.name
630
+ state.instance_id = instance.id
631
+ state.instance = instance
632
+ state.fingerprint = fingerprint
633
+ state.model_id = cfg.model.id
634
+ state.api_key = api_key
635
+ state.created_at = instance.created_at or utc_now()
636
+ state.last_activity = utc_now()
637
+ state.starting = True
638
+ state.state = InstanceState.PROVISIONING.value
639
+ state.hourly_cost_usd = instance.hourly_cost_usd
640
+ state.locked = False
641
+ state.locked_until = None
642
+ # Billing starts the moment the provider creates the instance,
643
+ # not at the next reconcile - start the clock now so a long
644
+ # provisioning/startup wait is never undercounted.
645
+ state.billed_seconds = 0.0
646
+ state.running_since = state.created_at
647
+ state.mark_session_start(state.created_at)
648
+ state.watchdog = WatchdogInfo(
649
+ installed=cfg.lifecycle.watchdog.enabled,
650
+ token=token,
651
+ port=cfg.lifecycle.watchdog.port,
652
+ url=instance.endpoint_url("watchdog"),
653
+ )
654
+ # Persist before the long wait: a crash here must still leave a
655
+ # destroyable record (spec criterion 4).
656
+ self.store.save(state)
657
+ self.events.emit(
658
+ ev.INSTANCE_CREATED,
659
+ instance_id=instance.id,
660
+ host_id=instance.host_id,
661
+ gpu_name=instance.gpu_name,
662
+ gpu_count=instance.gpu_count,
663
+ hourly_cost_usd=instance.hourly_cost_usd,
664
+ )
665
+
666
+ if not wait:
667
+ return state
668
+ return self.ensure_ready(state=state)
669
+
670
+ def _host_failed(
671
+ self,
672
+ state: RuntimeState,
673
+ exc: HostFailure,
674
+ attempt: int,
675
+ attempts: int,
676
+ retry: bool,
677
+ ) -> None:
678
+ instance = exc.instance
679
+ host_id = instance.host_id if instance else None
680
+ instance_id = instance.id if instance else state.instance_id
681
+ self.events.emit(
682
+ ev.HOST_FAILED,
683
+ instance_id=instance_id,
684
+ host_id=host_id,
685
+ reason=exc.reason,
686
+ error=str(exc),
687
+ attempt=attempt,
688
+ host_attempts=attempts,
689
+ )
690
+ line = f"! host {host_id or instance_id} failed ({exc.reason})"
691
+ if retry:
692
+ line += f"; trying another machine ({attempt + 1}/{attempts})"
693
+ self.echo(line)
694
+
695
+ def _discard_host(
696
+ self,
697
+ state: RuntimeState,
698
+ exc: HostFailure,
699
+ excluded: list[str],
700
+ bad_hosts: Optional["BadHosts"] = None,
701
+ ) -> bool:
702
+ """Destroy a machine that failed to boot so another can be rented.
703
+
704
+ False when it could not be destroyed: renting another would leave two
705
+ instances billing, so setup stops there.
706
+ """
707
+ instance = exc.instance
708
+ try:
709
+ if state.instance_id:
710
+ self._terminate(state, action=policy.ACTION_DESTROY, reason="host_failure")
711
+ elif instance is not None:
712
+ # Vanished: already out of the state. Destroying is idempotent
713
+ # and makes sure it is really gone before renting another.
714
+ self.provider.destroy(instance)
715
+ except JambuError as cleanup_exc:
716
+ self.echo(f"! could not destroy the failed instance: {cleanup_exc}")
717
+ return False
718
+ self.store.save(state)
719
+ host_id = instance.host_id if instance else None
720
+ if host_id:
721
+ excluded.append(host_id)
722
+ if bad_hosts is not None:
723
+ bad_hosts.mark(self.provider.name, host_id, exc.reason)
724
+ return True
725
+
726
+ def _fail_setup(self, state: RuntimeState, exc: BaseException) -> None:
727
+ cfg = self.config
728
+ self.events.emit(ev.SETUP_FAILED, error=str(exc), error_type=type(exc).__name__)
729
+ if cfg.lifecycle.cleanup_on_setup_failure and state.instance_id:
730
+ self.echo(f"! setup failed, cleaning up instance {state.instance_id}")
731
+ try:
732
+ self._terminate(state, action=policy.ACTION_DESTROY, reason="setup_failure")
733
+ self.events.emit(ev.CLEANUP_PERFORMED, instance_id=state.instance_id)
734
+ except JambuError as cleanup_exc:
735
+ self.events.emit(
736
+ ev.PROVIDER_ERROR, operation="cleanup", error=str(cleanup_exc)
737
+ )
738
+ self.echo(f"! cleanup failed: {cleanup_exc}")
739
+ self.store.save(state)
740
+
741
+ # ------------------------------------------------------------------
742
+ # readiness
743
+ # ------------------------------------------------------------------
744
+
745
+ def ensure_ready(self, state: Optional[RuntimeState] = None) -> RuntimeState:
746
+ """Guarantee the configured runtime is available (spec section 16)."""
747
+ with self.store.process_lock():
748
+ state = state if state is not None else self._reconcile(self.store.load())
749
+
750
+ if not state.has_instance():
751
+ self.store.save(state)
752
+ return self.setup()
753
+
754
+ instance = state.instance
755
+ if instance is None:
756
+ raise StateError("state has an instance id but no instance record")
757
+
758
+ if state.state == InstanceState.STOPPED.value:
759
+ if not self.provider.capabilities.stop:
760
+ raise UnsupportedCapabilityError(
761
+ f"provider '{self.provider.name}' cannot restart instances"
762
+ )
763
+ self.echo(f"> starting instance {instance.id}")
764
+ instance = self.provider.start(instance)
765
+ state.instance = instance
766
+ state.starting = True
767
+ state.mark_billable(True) # billing resumes now, not at the next reconcile
768
+ state.mark_session_start() # max_lifetime and budget restart with it
769
+ self.events.emit(ev.INSTANCE_STARTED, instance_id=instance.id)
770
+
771
+ instance = self._wait_running(state)
772
+ state.instance = instance
773
+ state.endpoint = instance.endpoint_url(ENDPOINT_NAME)
774
+ watchdog_url = instance.endpoint_url("watchdog")
775
+ if watchdog_url:
776
+ state.watchdog.url = watchdog_url
777
+ self.store.save(state)
778
+
779
+ self._connect_watchdog(state)
780
+
781
+ if self.config.runtime.engine != "none":
782
+ self._wait_runtime(state)
783
+
784
+ state.starting = False
785
+ state.state = InstanceState.READY.value
786
+ state.touch()
787
+ self.store.save(state)
788
+ return state
789
+
790
+ def _wait_running(self, state: RuntimeState) -> Instance:
791
+ """Wait for the machine itself (mostly the image pull), up to
792
+ health.boot_timeout. The container has not run yet, so every failure
793
+ here is the host's.
794
+ """
795
+ instance = state.instance
796
+ assert instance is not None
797
+ boot_timeout = self.config.boot_timeout_s
798
+ stall_timeout = self.config.pull_stall_timeout_s
799
+ deadline = time.time() + boot_timeout
800
+ announced = False
801
+ # Live, healthy pulls printed a new line every 1-8 minutes; stalled ones
802
+ # froze (or retried one layer) until boot_timeout, 30 minutes per host.
803
+ progress: Optional[str] = None
804
+ progress_at = time.time()
805
+ while time.time() < deadline:
806
+ status = self.provider.status(instance)
807
+ if not status.exists:
808
+ state.clear_instance()
809
+ self.store.save(state)
810
+ raise HostFailure(
811
+ f"instance {instance.id} vanished at the provider while starting",
812
+ reason="vanished while booting",
813
+ instance=instance,
814
+ )
815
+ current = status.instance or instance
816
+ if status.state is InstanceState.FAILED:
817
+ detail = (status.message or "").strip() or "no detail"
818
+ raise HostFailure(
819
+ f"instance {instance.id} failed: {detail}",
820
+ reason=detail.splitlines()[0][:160],
821
+ instance=current,
822
+ )
823
+ ready_ports = self._ports()
824
+ have_ports = all(
825
+ current.endpoints.get(name) is not None
826
+ for name in self._endpoint_names()
827
+ )
828
+ if status.state in (
829
+ InstanceState.READY,
830
+ InstanceState.BUSY,
831
+ InstanceState.IDLE,
832
+ ) and (have_ports or not ready_ports):
833
+ return current
834
+ moved = _pull_progress(status.message)
835
+ if moved is not None and moved != progress:
836
+ progress, progress_at = moved, time.time()
837
+ elif stall_timeout and time.time() - progress_at >= stall_timeout:
838
+ last = (status.message or "").strip().splitlines()
839
+ raise HostFailure(
840
+ f"instance {instance.id}'s image pull made no progress for "
841
+ f"{format_duration(stall_timeout)} (health.pull_stall_timeout)"
842
+ + (f"; last: {last[-1][:160]}" if last else ""),
843
+ reason=f"image pull stalled for {format_duration(stall_timeout)}",
844
+ instance=current,
845
+ )
846
+ if not announced:
847
+ announced = True
848
+ self.echo(" waiting for the machine to come up ...")
849
+ time.sleep(6)
850
+ raise HostFailure(
851
+ f"instance {instance.id} was not running after "
852
+ f"{format_duration(boot_timeout)} (health.boot_timeout)",
853
+ reason=f"image pull timed out after {format_duration(boot_timeout)}",
854
+ instance=instance,
855
+ )
856
+
857
+ def _endpoint_names(self) -> list[str]:
858
+ names = []
859
+ if self.config.runtime.engine != "none":
860
+ names.append(ENDPOINT_NAME)
861
+ if self.config.lifecycle.watchdog.enabled:
862
+ names.append("watchdog")
863
+ return names
864
+
865
+ def _connect_watchdog(self, state: RuntimeState) -> None:
866
+ if not self.config.lifecycle.watchdog.enabled:
867
+ return
868
+ client = self.beacon(state)
869
+ if client is None:
870
+ state.watchdog.last_error = "no watchdog endpoint published"
871
+ self.echo("! watchdog port was not published; lifecycle guard is local-only")
872
+ return
873
+
874
+ deadline = time.time() + 90
875
+ while time.time() < deadline:
876
+ if client.ping():
877
+ client.identify(state.instance_id or "", state.hourly_cost_usd)
878
+ client.mark_starting()
879
+ state.watchdog.installed = True
880
+ state.watchdog.last_contact = utc_now()
881
+ state.watchdog.last_error = None
882
+ self.echo(f"+ lifecycle watchdog live at {state.watchdog.url}")
883
+ self.store.save(state)
884
+ return
885
+ time.sleep(5)
886
+
887
+ state.watchdog.last_error = "did not answer within 90s"
888
+ self.echo(
889
+ "! the remote watchdog never answered. The instance is NOT protected "
890
+ "by an independent guard - check `gpu logs`, and run "
891
+ "`gpu destroy` if this is unexpected."
892
+ )
893
+
894
+ def _wait_runtime(self, state: RuntimeState) -> None:
895
+ endpoint = state.endpoint
896
+ if not endpoint:
897
+ raise RuntimeStartupError(
898
+ "the model port was never published by the provider; cannot reach the runtime"
899
+ )
900
+ url = self.runtime.health_url(endpoint)
901
+ self.events.emit(
902
+ ev.RUNTIME_STARTING, endpoint=endpoint, engine=self.config.runtime.engine
903
+ )
904
+ self.echo(f"> waiting for {self.runtime.describe()} at {endpoint}")
905
+ started = time.time()
906
+
907
+ def _progress(attempt: int, remaining: float, detail: str) -> None:
908
+ if attempt % 4 == 0:
909
+ self.echo(
910
+ f" still starting ({int(time.time() - started)}s elapsed, "
911
+ f"{int(remaining)}s left, last probe: {detail})"
912
+ )
913
+
914
+ def _abort() -> Optional[str]:
915
+ instance = state.instance
916
+ if instance is None:
917
+ return None
918
+ try:
919
+ status = self.provider.status(instance)
920
+ except ProviderError:
921
+ return None
922
+ if not status.exists:
923
+ return "the instance disappeared at the provider"
924
+ if status.state is InstanceState.FAILED:
925
+ return status.message or "the instance entered a failed state"
926
+ if status.state is InstanceState.STOPPED:
927
+ return "the instance was stopped"
928
+ reason = self._runtime_exited(state)
929
+ if reason and _HOST_NETWORK_FAILURE.search(reason):
930
+ # The model server died fetching weights over a broken network:
931
+ # the machine's fault, so another host gets a chance.
932
+ raise HostFailure(
933
+ f"model server did not start: {reason}",
934
+ reason="model download failed on this host's network",
935
+ instance=state.instance,
936
+ )
937
+ return reason
938
+
939
+ wait_for_healthy(
940
+ url,
941
+ timeout=self.config.startup_timeout_s,
942
+ interval=self.config.health_interval_s,
943
+ on_attempt=_progress,
944
+ should_abort=_abort,
945
+ )
946
+ client = self.beacon(state)
947
+ if client is not None:
948
+ client.mark_ready()
949
+ self.events.emit(
950
+ ev.RUNTIME_READY,
951
+ endpoint=endpoint,
952
+ startup_seconds=round(time.time() - started, 1),
953
+ )
954
+ self.echo(f"+ runtime ready: {endpoint}")
955
+
956
+ def _runtime_exited(self, state: RuntimeState) -> Optional[str]:
957
+ """The watchdog saw the model server process die: say why, from its log."""
958
+ client = self.beacon(state)
959
+ if client is None:
960
+ return None
961
+ snapshot = client.try_state()
962
+ if not snapshot or snapshot.get("runtime_alive") is not False:
963
+ return None
964
+ reason = "the model server process exited on the instance"
965
+ try:
966
+ tail = client.logs("runtime", STARTUP_FAILURE_LOG_LINES)
967
+ except WatchdogUnavailable:
968
+ tail = []
969
+ if not tail:
970
+ return reason + " (its log is empty or unreadable)"
971
+ return reason + ". Last lines of its log:\n" + "\n".join(
972
+ " " + line for line in tail
973
+ )
974
+
975
+ # ------------------------------------------------------------------
976
+ # run (spec section 16)
977
+ # ------------------------------------------------------------------
978
+
979
+ def api_key_for(self, state: RuntimeState) -> str:
980
+ # An instance from before generated keys runs without one; vLLM then
981
+ # accepts any value, and OpenAI clients refuse an empty one.
982
+ return state.api_key or self.config.runtime.api_key or "jambu-local"
983
+
984
+ def served_model(self) -> str:
985
+ """The name clients pass as `model` in OpenAI requests."""
986
+ return self.config.model.served_name or self.config.model.id
987
+
988
+ def connection(self, state: RuntimeState) -> dict[str, Any]:
989
+ """How an application reaches the model: the stable integration contract."""
990
+ endpoint = state.endpoint
991
+ return {
992
+ "instance_id": state.instance_id,
993
+ "state": state.state if state.instance_id else "none",
994
+ "endpoint": endpoint,
995
+ "base_url": f"{endpoint.rstrip('/')}/v1" if endpoint else None,
996
+ "api_key": self.api_key_for(state) if endpoint else None,
997
+ "model": self.served_model(),
998
+ "hourly_cost_usd": state.hourly_cost_usd,
999
+ }
1000
+
1001
+ def workload_env(self, state: RuntimeState, workload_id: str) -> dict[str, str]:
1002
+ endpoint = state.endpoint or ""
1003
+ env: dict[str, str] = {
1004
+ "JAMBU_PROVIDER": self.provider.name,
1005
+ "JAMBU_INSTANCE_ID": state.instance_id or "",
1006
+ "JAMBU_WORKLOAD_ID": workload_id,
1007
+ "JAMBU_MODEL_ID": self.served_model(),
1008
+ }
1009
+ if endpoint:
1010
+ env["JAMBU_GPU_ENDPOINT"] = endpoint
1011
+ env["OPENAI_BASE_URL"] = f"{endpoint.rstrip('/')}/v1"
1012
+ env["OPENAI_API_KEY"] = self.api_key_for(state)
1013
+ if state.instance and state.instance.ssh_host:
1014
+ env["JAMBU_SSH_HOST"] = state.instance.ssh_host
1015
+ env["JAMBU_SSH_PORT"] = str(state.instance.ssh_port or 22)
1016
+ env.update({k: str(v) for k, v in self.config.workload.env.items()})
1017
+ return env
1018
+
1019
+ def run(self, command: Sequence[str], label: str = "") -> int:
1020
+ if not command:
1021
+ raise ConfigError("nothing to run: pass a command, e.g. `gpu run python x.py`")
1022
+
1023
+ state = self.ensure_ready()
1024
+ manager = ExecutionManager(
1025
+ self.store,
1026
+ self.events,
1027
+ beacon=self.beacon(state),
1028
+ heartbeat_interval=self.config.heartbeat_interval_s,
1029
+ )
1030
+ record = manager.register(command, label=label)
1031
+ workload = Workload(
1032
+ id=record.id,
1033
+ command=list(command),
1034
+ label=record.label,
1035
+ cwd=self.config.workload.workdir or str(self.config.project_dir),
1036
+ env=self.workload_env(state, record.id),
1037
+ mode=self.config.workload.mode,
1038
+ )
1039
+
1040
+ returncode: Optional[int] = None
1041
+ try:
1042
+ with Heartbeat(
1043
+ record.id,
1044
+ self.config.heartbeat_interval_s,
1045
+ self.store,
1046
+ self.beacon(state),
1047
+ ):
1048
+ if workload.mode == "remote":
1049
+ self.provider.require_capability("remote_exec")
1050
+ execution = self.provider.run(state.instance, workload)
1051
+ else:
1052
+ execution = manager.run_local(workload, record)
1053
+ returncode = execution.returncode
1054
+ finally:
1055
+ manager.finish(record, returncode)
1056
+
1057
+ if returncode:
1058
+ raise WorkloadFailed(
1059
+ f"workload exited with code {returncode}", returncode=returncode
1060
+ )
1061
+ return 0
1062
+
1063
+ # ------------------------------------------------------------------
1064
+ # status (spec section 18)
1065
+ # ------------------------------------------------------------------
1066
+
1067
+ def _observe(self) -> tuple[RuntimeState, bool]:
1068
+ """Reconciled state, or the last saved one when another command holds the lock."""
1069
+ try:
1070
+ with self.store.process_lock(timeout=READ_LOCK_WAIT_S):
1071
+ state = self._reconcile(self.store.load())
1072
+ self.store.save(state)
1073
+ return state, False
1074
+ except LockBusy:
1075
+ return self.store.load(), True
1076
+
1077
+ def status(self) -> dict[str, Any]:
1078
+ state, busy = self._observe()
1079
+
1080
+ cfg = self.policy_config(state.hourly_cost_usd)
1081
+ idle_skipped = self._idle_blind(state)
1082
+ if idle_skipped:
1083
+ cfg.idle_timeout_s = None
1084
+ snap = self.snapshot(state)
1085
+ decision = policy.evaluate(snap, cfg)
1086
+ if idle_skipped:
1087
+ decision.detail["idle_skipped"] = "watchdog_unreachable"
1088
+ remote = None
1089
+ client = self.beacon(state)
1090
+ if client is not None:
1091
+ remote = client.try_state()
1092
+
1093
+ return {
1094
+ "provider": self.provider.name,
1095
+ "instance_id": state.instance_id,
1096
+ "state": state.state if state.instance_id else "none",
1097
+ "model": state.model_id or self.config.model.id,
1098
+ "endpoint": state.endpoint,
1099
+ "base_url": f"{state.endpoint.rstrip('/')}/v1" if state.endpoint else None,
1100
+ "created_at": state.created_at,
1101
+ "age_seconds": snap.age_seconds() if state.created_at else None,
1102
+ "idle_seconds": snap.idle_seconds(cfg),
1103
+ "locked": snap.lock_active(cfg),
1104
+ "locked_until": state.locked_until,
1105
+ "hourly_cost_usd": state.hourly_cost_usd,
1106
+ "session_cost_usd": state.session_cost_usd,
1107
+ "session_started_at": state.session_started_at,
1108
+ "stopped_since": state.stopped_since,
1109
+ "workloads": [
1110
+ {
1111
+ "id": w.id,
1112
+ "label": w.label,
1113
+ "state": w.state,
1114
+ "started_at": w.started_at,
1115
+ "last_heartbeat": w.last_heartbeat,
1116
+ "returncode": w.returncode,
1117
+ }
1118
+ for w in state.workloads.values()
1119
+ ],
1120
+ "decision": decision.to_dict(),
1121
+ "policy": cfg.to_dict(),
1122
+ "watchdog": {
1123
+ "enabled": self.config.lifecycle.watchdog.enabled,
1124
+ "installed": state.watchdog.installed,
1125
+ "url": state.watchdog.url,
1126
+ "reachable": remote is not None,
1127
+ "last_error": state.watchdog.last_error,
1128
+ "remote_state": remote,
1129
+ },
1130
+ "capabilities": self.provider.capabilities.__dict__,
1131
+ # True while another command (a setup) holds the lock: the state is
1132
+ # the one it last saved, not freshly asked of the provider.
1133
+ "busy": busy,
1134
+ }
1135
+
1136
+ # ------------------------------------------------------------------
1137
+ # lock / unlock (spec section 11)
1138
+ # ------------------------------------------------------------------
1139
+
1140
+ def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict[str, Any]:
1141
+ if ttl_seconds is None and not self.config.lifecycle.allow_indefinite_lock:
1142
+ raise ConfigError(
1143
+ "indefinite locks are disabled. Pass `--for 2h`, or set "
1144
+ "lifecycle.allow_indefinite_lock: true in jambu.yaml."
1145
+ )
1146
+ with self.store.process_lock():
1147
+ state = self._reconcile(self.store.load())
1148
+ if not state.has_instance():
1149
+ raise StateError("no instance to lock")
1150
+ until = utc_now() + ttl_seconds if ttl_seconds else None
1151
+ client = self.beacon(state)
1152
+ if client is not None and client.try_request(
1153
+ "POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason}
1154
+ ) is None:
1155
+ state.watchdog.last_error = "unreachable"
1156
+ self.echo(
1157
+ "! the remote watchdog did not accept the lock; it may still stop "
1158
+ "the instance. Check `gpu status`."
1159
+ )
1160
+ state.locked = True
1161
+ state.locked_until = until
1162
+ state.lock_reason = reason
1163
+ state.touch()
1164
+ self.store.save(state)
1165
+ self.events.emit(
1166
+ ev.INSTANCE_LOCKED,
1167
+ instance_id=state.instance_id,
1168
+ locked_until=until,
1169
+ reason=reason,
1170
+ )
1171
+ return {"locked": True, "locked_until": until}
1172
+
1173
+ def unlock(self, reason: str = "manual") -> dict[str, Any]:
1174
+ with self.store.process_lock():
1175
+ state = self._reconcile(self.store.load())
1176
+ client = self.beacon(state)
1177
+ if client is not None:
1178
+ client.try_request("POST", "/unlock", {"reason": reason})
1179
+ state.locked = False
1180
+ state.locked_until = None
1181
+ state.lock_reason = ""
1182
+ state.touch()
1183
+ self.store.save(state)
1184
+ self.events.emit(ev.INSTANCE_UNLOCKED, instance_id=state.instance_id, reason=reason)
1185
+ return {"locked": False}
1186
+
1187
+ # ------------------------------------------------------------------
1188
+ # stop / destroy
1189
+ # ------------------------------------------------------------------
1190
+
1191
+ def stop(self, reason: str = "manual") -> dict[str, Any]:
1192
+ with self.store.process_lock():
1193
+ state = self._reconcile(self.store.load())
1194
+ if not state.has_instance():
1195
+ self.echo("= nothing to stop")
1196
+ return {"changed": False, "state": state.state}
1197
+ if state.state == InstanceState.STOPPED.value:
1198
+ self.echo(f"= instance {state.instance_id} is already stopped")
1199
+ return {"changed": False, "state": state.state}
1200
+ action = (
1201
+ policy.ACTION_STOP if self.provider.capabilities.stop else policy.ACTION_DESTROY
1202
+ )
1203
+ self._terminate(state, action=action, reason=reason)
1204
+ self.store.save(state)
1205
+ return {"changed": True, "state": state.state, "action": action}
1206
+
1207
+ def destroy(self, reason: str = "manual") -> dict[str, Any]:
1208
+ with self.store.process_lock():
1209
+ state = self._reconcile(self.store.load())
1210
+ if not state.instance_id:
1211
+ self.echo("= nothing to destroy")
1212
+ return {"changed": False, "state": state.state}
1213
+ self._terminate(state, action=policy.ACTION_DESTROY, reason=reason)
1214
+ self.store.save(state)
1215
+ return {"changed": True, "state": state.state}
1216
+
1217
+ def _terminate(self, state: RuntimeState, action: str, reason: str) -> None:
1218
+ instance = state.instance or (
1219
+ Instance(id=state.instance_id, provider=self.provider.name)
1220
+ if state.instance_id
1221
+ else None
1222
+ )
1223
+ if instance is None:
1224
+ return
1225
+ instance_id = instance.id
1226
+ try:
1227
+ if action == policy.ACTION_DESTROY:
1228
+ self.provider.destroy(instance)
1229
+ else:
1230
+ self.provider.stop(instance)
1231
+ except ProviderError as exc:
1232
+ self.events.emit(
1233
+ ev.PROVIDER_ERROR, operation=action, instance_id=instance_id, error=str(exc)
1234
+ )
1235
+ raise
1236
+
1237
+ if action == policy.ACTION_DESTROY:
1238
+ state.clear_instance(InstanceState.DESTROYED.value)
1239
+ self.events.emit(ev.INSTANCE_DESTROYED, instance_id=instance_id, reason=reason)
1240
+ self.echo(f"- instance {instance_id} destroyed ({reason})")
1241
+ else:
1242
+ state.state = InstanceState.STOPPED.value
1243
+ state.starting = False
1244
+ state.mark_billable(False)
1245
+ state.mark_stopped()
1246
+ if state.hourly_cost_usd:
1247
+ state.session_cost_usd = round(
1248
+ state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
1249
+ )
1250
+ for record in state.workloads.values():
1251
+ if record.state in ("running", "queued"):
1252
+ record.state = "failed"
1253
+ record.finished_at = utc_now()
1254
+ self.events.emit(ev.INSTANCE_STOPPED, instance_id=instance_id, reason=reason)
1255
+ self.echo(f"- instance {instance_id} stopped ({reason})")
1256
+
1257
+ # ------------------------------------------------------------------
1258
+ # guard (external controller path, spec section 14 alternative)
1259
+ # ------------------------------------------------------------------
1260
+
1261
+ def guard_tick(self, apply: bool = True) -> dict[str, Any]:
1262
+ """Evaluate the lifecycle rule locally and optionally enforce it.
1263
+
1264
+ The remote watchdog does this on the instance. This entry point exists
1265
+ for a scheduled external controller (CI job, cron) and for `status`.
1266
+ """
1267
+ with self.store.process_lock():
1268
+ state = self._reconcile(self.store.load())
1269
+ if not state.has_instance():
1270
+ self.store.save(state)
1271
+ return {"action": policy.ACTION_NONE, "reason": "no_instance"}
1272
+
1273
+ cfg = self.policy_config(state.hourly_cost_usd)
1274
+ idle_skipped = self._idle_blind(state)
1275
+ if idle_skipped:
1276
+ cfg.idle_timeout_s = None
1277
+ snap = self.snapshot(state)
1278
+
1279
+ if state.locked and snap.lock_expired(cfg):
1280
+ state.locked = False
1281
+ state.locked_until = None
1282
+ self.events.emit(ev.INSTANCE_UNLOCKED, reason="lock_expired")
1283
+ snap = self.snapshot(state)
1284
+
1285
+ decision = policy.evaluate(snap, cfg)
1286
+ if idle_skipped:
1287
+ decision.detail["idle_skipped"] = "watchdog_unreachable"
1288
+ if decision.terminates and apply:
1289
+ self.events.emit(
1290
+ ev.LIFECYCLE_GUARD_TRIGGERED,
1291
+ instance_id=state.instance_id,
1292
+ action=decision.action,
1293
+ reason=decision.reason,
1294
+ **decision.detail,
1295
+ )
1296
+ self._terminate(state, action=decision.action, reason=decision.reason or "guard")
1297
+ self.store.save(state)
1298
+ return decision.to_dict()
1299
+
1300
+ # ------------------------------------------------------------------
1301
+ # logs
1302
+ # ------------------------------------------------------------------
1303
+
1304
+ def logs(self, limit: int = 200, source: Optional[str] = None) -> list[str]:
1305
+ return self.read_logs(limit, source)[1]
1306
+
1307
+ def read_logs(
1308
+ self, limit: int = 200, source: Optional[str] = None
1309
+ ) -> tuple[str, list[str]]:
1310
+ """(source used, lines). ``source`` None means the runtime log when the
1311
+ watchdog can serve it, else the provider's container log."""
1312
+ if source is not None and source not in LOG_SOURCES:
1313
+ raise ConfigError(
1314
+ f"unknown log source '{source}' (choose from {', '.join(LOG_SOURCES)})"
1315
+ )
1316
+ state, _ = self._observe()
1317
+ if not state.instance:
1318
+ raise StateError("no instance to read logs from")
1319
+ if source != "provider":
1320
+ name = source or "runtime"
1321
+ client = self.beacon(state)
1322
+ try:
1323
+ if client is None:
1324
+ raise WatchdogUnavailable("no watchdog installed on this instance")
1325
+ return name, client.logs(name, limit)
1326
+ except WatchdogUnavailable as exc:
1327
+ if source is not None:
1328
+ raise StateError(
1329
+ f"cannot read the {source} log: {exc}. "
1330
+ "`gpu logs --source provider` shows the container log instead."
1331
+ ) from exc
1332
+ self.provider.require_capability("logs")
1333
+ stream = self.provider.logs(state.instance)
1334
+ lines = list(stream)
1335
+ return "provider", lines[-limit:]