jambu-gpu 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,951 @@
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 time
11
+ from typing import Any, Callable, Optional, Sequence
12
+
13
+ from .. import watchdog as watchdog_pkg
14
+ from ..providers.base import GPUProvider
15
+ from ..providers.registry import load_builtin_providers
16
+ from ..runtime import runtime_registry
17
+ from ..runtime.base import ENDPOINT_NAME, ModelRuntime
18
+ from ..watchdog.builder import build_agent_config, build_agent_source, build_onstart_script
19
+ from . import events as ev
20
+ from . import policy
21
+ from .beacon import WatchdogClient
22
+ from .config import RuntimeConfig
23
+ from .credentials import CredentialResolver
24
+ from .errors import (
25
+ BudgetExceededError,
26
+ ConfigError,
27
+ JambuError,
28
+ ProviderError,
29
+ RuntimeStartupError,
30
+ StateError,
31
+ UnsupportedCapabilityError,
32
+ WorkloadFailed,
33
+ )
34
+ from .events import EventLog
35
+ from .execution import ExecutionManager, Heartbeat
36
+ from .health import wait_for_healthy
37
+ from .models import (
38
+ ComputeSpec,
39
+ GpuSpec,
40
+ Instance,
41
+ InstanceState,
42
+ ValidationResult,
43
+ Workload,
44
+ )
45
+ from .state import RuntimeState, StateStore, WatchdogInfo, utc_now
46
+
47
+ Echo = Callable[[str], None]
48
+
49
+
50
+ def _noop(_message: str) -> None:
51
+ return None
52
+
53
+
54
+ class Engine:
55
+ def __init__(
56
+ self,
57
+ config: RuntimeConfig,
58
+ provider: Optional[GPUProvider] = None,
59
+ echo: Optional[Echo] = None,
60
+ credentials: Optional[CredentialResolver] = None,
61
+ ) -> None:
62
+ self.config = config
63
+ self.echo = echo or _noop
64
+ self.credentials = credentials or CredentialResolver(
65
+ config.project_dir, profile=config.provider.profile
66
+ )
67
+ registry = load_builtin_providers()
68
+ self.provider = provider or registry.get(config, self.credentials)
69
+ self.runtime: ModelRuntime = runtime_registry.get(config)
70
+ self.store = StateStore(config.state_dir)
71
+ self.events = EventLog(self.store.logs_dir).bind(provider=self.provider.name)
72
+
73
+ # ------------------------------------------------------------------
74
+ # policy plumbing
75
+ # ------------------------------------------------------------------
76
+
77
+ def policy_config(self, hourly_cost_usd: Optional[float] = None) -> policy.PolicyConfig:
78
+ caps = self.provider.capabilities
79
+ cfg = self.config
80
+ return policy.PolicyConfig(
81
+ idle_timeout_s=cfg.idle_timeout_s,
82
+ max_lifetime_s=cfg.max_lifetime_s,
83
+ heartbeat_grace_s=cfg.heartbeat_grace_s,
84
+ auto_stop=cfg.lifecycle.auto_stop,
85
+ auto_destroy=cfg.lifecycle.auto_destroy,
86
+ allow_indefinite_lock=cfg.lifecycle.allow_indefinite_lock,
87
+ stop_supported=caps.stop,
88
+ destroy_supported=caps.destroy,
89
+ stop_eliminates_billing=caps.stop_eliminates_billing,
90
+ hourly_cost_usd=hourly_cost_usd,
91
+ max_session_cost_usd=cfg.budget.max_session_cost_usd,
92
+ )
93
+
94
+ def snapshot(self, state: RuntimeState) -> policy.Snapshot:
95
+ now = utc_now()
96
+ return policy.Snapshot(
97
+ now=now,
98
+ created_at=state.created_at or now,
99
+ last_activity=state.last_activity or state.created_at or now,
100
+ workloads=[w.to_policy() for w in state.workloads.values()],
101
+ starting=state.starting,
102
+ locked=state.locked,
103
+ locked_until=state.locked_until,
104
+ provider_state=state.state,
105
+ )
106
+
107
+ def beacon(self, state: RuntimeState) -> Optional[WatchdogClient]:
108
+ info = state.watchdog
109
+ if not info.installed or not info.url:
110
+ return None
111
+ return WatchdogClient(info.url, info.token)
112
+
113
+ # ------------------------------------------------------------------
114
+ # validate (spec section 15, steps 1-5)
115
+ # ------------------------------------------------------------------
116
+
117
+ def validate(self, check_provider: bool = True) -> ValidationResult:
118
+ result = ValidationResult()
119
+ cfg = self.config
120
+
121
+ if cfg.lifecycle.idle_timeout is None and cfg.lifecycle.max_lifetime is None:
122
+ result.error(
123
+ "lifecycle needs at least one of idle_timeout or max_lifetime; "
124
+ "without a bound an abandoned GPU bills forever",
125
+ "lifecycle",
126
+ )
127
+ if not cfg.lifecycle.auto_stop and not cfg.lifecycle.auto_destroy:
128
+ result.warn(
129
+ "auto_stop and auto_destroy are both false: the guard will report "
130
+ "but never shut anything down",
131
+ "lifecycle",
132
+ )
133
+ if not cfg.lifecycle.watchdog.enabled:
134
+ result.warn(
135
+ "lifecycle.watchdog.enabled is false: shutdown depends on this CLI "
136
+ "staying alive (spec section 14)",
137
+ "lifecycle.watchdog",
138
+ )
139
+ if cfg.lifecycle.allow_indefinite_lock:
140
+ result.warn(
141
+ "allow_indefinite_lock is true: a forgotten lock defeats cost protection",
142
+ "lifecycle.allow_indefinite_lock",
143
+ )
144
+ if cfg.budget.max_hourly_cost_usd is None:
145
+ result.warn("no budget.max_hourly_cost_usd set", "budget")
146
+
147
+ result.merge(self.runtime.validate())
148
+
149
+ if cfg.model.hf_token_env and not self.credentials.get(cfg.model.hf_token_env):
150
+ result.warn(
151
+ f"{cfg.model.hf_token_env} is not set; gated models will fail to download",
152
+ "model.hf_token_env",
153
+ )
154
+
155
+ if check_provider:
156
+ try:
157
+ result.merge(self.provider.validate(cfg))
158
+ except JambuError as exc:
159
+ result.error(str(exc), "provider")
160
+ return result
161
+
162
+ # ------------------------------------------------------------------
163
+ # reconciliation (spec section 18)
164
+ # ------------------------------------------------------------------
165
+
166
+ def reconcile(self, state: Optional[RuntimeState] = None) -> RuntimeState:
167
+ """Merge local state with provider truth. Repairs local state in place."""
168
+ owns_lock = state is None
169
+ if owns_lock:
170
+ with self.store.process_lock():
171
+ state = self.store.load()
172
+ reconciled = self._reconcile(state)
173
+ self.store.save(reconciled)
174
+ return reconciled
175
+ return self._reconcile(state)
176
+
177
+ def _reconcile(self, state: RuntimeState) -> RuntimeState:
178
+ if not state.instance_id:
179
+ state.state = InstanceState.DESTROYED.value
180
+ return state
181
+
182
+ instance = state.instance or Instance(id=state.instance_id, provider=self.provider.name)
183
+ try:
184
+ status = self.provider.status(instance)
185
+ except ProviderError as exc:
186
+ self.events.emit(ev.PROVIDER_ERROR, operation="status", error=str(exc))
187
+ return state # keep local state; the API blip is not authoritative
188
+
189
+ previous = state.state
190
+ if not status.exists:
191
+ state.clear_instance(InstanceState.DESTROYED.value)
192
+ self.events.emit(
193
+ ev.STATE_RECONCILED,
194
+ previous_state=previous,
195
+ state=InstanceState.DESTROYED.value,
196
+ reason="instance missing at provider",
197
+ )
198
+ return state
199
+
200
+ current = status.instance or instance
201
+ state.instance = current
202
+ state.instance_id = current.id
203
+ state.hourly_cost_usd = status.hourly_cost_usd or state.hourly_cost_usd
204
+ if current.created_at:
205
+ state.created_at = current.created_at
206
+ endpoint = current.endpoint_url(ENDPOINT_NAME)
207
+ if endpoint:
208
+ state.endpoint = endpoint
209
+
210
+ watchdog_url = current.endpoint_url("watchdog")
211
+ if watchdog_url and state.watchdog.token:
212
+ state.watchdog.url = watchdog_url
213
+ state.watchdog.installed = True
214
+
215
+ # The watchdog, when reachable, is the authority on activity.
216
+ remote = None
217
+ client = self.beacon(state)
218
+ if client is not None:
219
+ remote = client.try_state()
220
+ if remote:
221
+ state.watchdog.last_contact = utc_now()
222
+ state.watchdog.last_error = None
223
+ state.locked = bool(remote.get("locked"))
224
+ state.locked_until = remote.get("locked_until")
225
+ state.starting = bool(remote.get("starting"))
226
+ if remote.get("last_activity"):
227
+ state.last_activity = max(
228
+ state.last_activity or 0.0, float(remote["last_activity"])
229
+ )
230
+ if remote.get("terminated"):
231
+ state.state = InstanceState.STOPPED.value
232
+ pulled = client.events(limit=200)
233
+ if pulled:
234
+ self.events.extend(pulled)
235
+ elif state.watchdog.installed:
236
+ state.watchdog.last_error = "unreachable"
237
+
238
+ if status.state in (InstanceState.READY, InstanceState.BUSY, InstanceState.IDLE):
239
+ derived = policy.derive_state(self.snapshot(state), self.policy_config())
240
+ state.state = derived if derived != InstanceState.UNKNOWN.value else status.state.value
241
+ else:
242
+ state.state = status.state.value
243
+ if status.state in (InstanceState.STOPPED, InstanceState.FAILED):
244
+ for record in state.workloads.values():
245
+ if record.state in ("running", "queued"):
246
+ record.state = "failed"
247
+ record.finished_at = utc_now()
248
+
249
+ # Track actual billable time, not wall-clock time since creation - a
250
+ # `status` checked long after auto-stop must not keep inflating cost.
251
+ state.mark_billable(InstanceState(state.state).is_billable_compute)
252
+ if state.hourly_cost_usd:
253
+ state.session_cost_usd = round(
254
+ state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
255
+ )
256
+
257
+ if previous != state.state:
258
+ self.events.emit(
259
+ ev.STATE_RECONCILED, previous_state=previous, state=state.state
260
+ )
261
+ return state
262
+
263
+ # ------------------------------------------------------------------
264
+ # setup (spec section 15)
265
+ # ------------------------------------------------------------------
266
+
267
+ def build_spec(self, onstart: str = "") -> ComputeSpec:
268
+ cfg = self.config
269
+ env = dict(self.runtime.env())
270
+ hf_token = (
271
+ self.credentials.get(cfg.model.hf_token_env) if cfg.model.hf_token_env else None
272
+ )
273
+ if hf_token:
274
+ env["HF_TOKEN"] = hf_token
275
+ env["HUGGING_FACE_HUB_TOKEN"] = hf_token
276
+ return ComputeSpec(
277
+ gpu=GpuSpec(
278
+ min_vram_gb=cfg.compute.gpu.min_vram_gb,
279
+ count=cfg.compute.gpu.count,
280
+ name_filter=cfg.compute.gpu.name_filter,
281
+ cuda_min=cfg.compute.gpu.cuda_min,
282
+ ),
283
+ disk_gb=cfg.compute.disk_gb,
284
+ image=self.runtime.image(),
285
+ ports=self._ports(),
286
+ env=env,
287
+ onstart=onstart,
288
+ label=f"jambu-{cfg.model.id.split('/')[-1][:24]}-{cfg.fingerprint()[:6]}",
289
+ spot=cfg.compute.spot,
290
+ region=cfg.compute.region,
291
+ max_hourly_cost_usd=cfg.budget.max_hourly_cost_usd,
292
+ provider_options=dict(cfg.provider.options),
293
+ )
294
+
295
+ def _ports(self) -> list[int]:
296
+ ports = list(self.runtime.ports())
297
+ if self.config.lifecycle.watchdog.enabled:
298
+ ports.append(self.config.lifecycle.watchdog.port)
299
+ return ports
300
+
301
+ def _build_onstart(self, token: str) -> str:
302
+ cfg = self.config
303
+ enabled = cfg.lifecycle.watchdog.enabled
304
+ spec = self.provider.watchdog_spec(None) if enabled else None
305
+ if enabled and spec is None:
306
+ raise UnsupportedCapabilityError(
307
+ f"provider '{self.provider.name}' supplies no watchdog actions; "
308
+ "set lifecycle.watchdog.enabled: false to proceed without remote "
309
+ "lifecycle enforcement"
310
+ )
311
+ agent_source = build_agent_source(spec.actions_source if spec else None)
312
+ agent_config = build_agent_config(
313
+ provider=self.provider.name,
314
+ instance_id="", # resolved by /identify or the provider's own env
315
+ token=token,
316
+ port=cfg.lifecycle.watchdog.port,
317
+ interval_s=cfg.watchdog_interval_s,
318
+ startup_grace_s=cfg.startup_timeout_s + 300.0,
319
+ policy=self.policy_config(),
320
+ created_at=utc_now(),
321
+ watchdog_spec=spec,
322
+ )
323
+ return build_onstart_script(
324
+ agent_source=agent_source,
325
+ agent_config=agent_config,
326
+ runtime_start_command=self.runtime.start_command(),
327
+ runtime_env=self.runtime.env(),
328
+ watchdog_enabled=enabled,
329
+ )
330
+
331
+ def _check_budget(self, spec: ComputeSpec, allow_override: bool) -> Optional[float]:
332
+ ceiling = self.config.budget.max_hourly_cost_usd
333
+ if not self.provider.capabilities.pricing_query:
334
+ if ceiling and not allow_override:
335
+ self.echo(
336
+ "! provider cannot query pricing; budget.max_hourly_cost_usd "
337
+ "cannot be enforced before provisioning"
338
+ )
339
+ return None
340
+ offers = self.provider.find_offers(spec, limit=5)
341
+ if not offers:
342
+ return None
343
+ cheapest = offers[0]
344
+ if ceiling and cheapest.hourly_cost_usd > float(ceiling):
345
+ if not allow_override:
346
+ self.events.emit(
347
+ ev.BUDGET_REJECTED,
348
+ cheapest_hourly_cost_usd=cheapest.hourly_cost_usd,
349
+ max_hourly_cost_usd=float(ceiling),
350
+ )
351
+ raise BudgetExceededError(
352
+ f"cheapest matching offer is ${cheapest.hourly_cost_usd:.3f}/h, above "
353
+ f"budget.max_hourly_cost_usd (${float(ceiling):.3f}/h). "
354
+ "Re-run with --allow-cost-override to proceed anyway."
355
+ )
356
+ self.echo(
357
+ f"! cost override: ${cheapest.hourly_cost_usd:.3f}/h exceeds the "
358
+ f"${float(ceiling):.3f}/h budget"
359
+ )
360
+ return cheapest.hourly_cost_usd
361
+
362
+ def setup(
363
+ self,
364
+ *,
365
+ recreate: bool = False,
366
+ allow_cost_override: bool = False,
367
+ wait: bool = True,
368
+ ) -> RuntimeState:
369
+ cfg = self.config
370
+ fingerprint = cfg.fingerprint()
371
+
372
+ with self.store.process_lock():
373
+ state = self._reconcile(self.store.load())
374
+
375
+ # -- idempotency (spec section 23) -----------------------------
376
+ if state.has_instance():
377
+ if state.fingerprint != fingerprint and not recreate:
378
+ raise ConfigError(
379
+ f"instance {state.instance_id} was provisioned for a different "
380
+ "runtime (jambu.yaml changed since setup). Re-run with "
381
+ "`--recreate` to replace it, or `gpu destroy` first."
382
+ )
383
+ if not recreate:
384
+ self.echo(f"= reusing instance {state.instance_id} ({state.state})")
385
+ self.store.save(state)
386
+ return self.ensure_ready(state=state) if wait else state
387
+ self.echo(f"~ replacing instance {state.instance_id}")
388
+ self._terminate(state, action=policy.ACTION_DESTROY, reason="recreate")
389
+
390
+ # -- provision ------------------------------------------------
391
+ token = watchdog_pkg.generate_token()
392
+ onstart = self._build_onstart(token)
393
+ spec = self.build_spec(onstart)
394
+
395
+ self.events.emit(
396
+ ev.PROVISION_REQUESTED,
397
+ model=cfg.model.id,
398
+ engine=cfg.runtime.engine,
399
+ gpu_count=cfg.compute.gpu.count,
400
+ min_vram_gb=cfg.compute.gpu.min_vram_gb,
401
+ fingerprint=fingerprint,
402
+ )
403
+ self.echo(
404
+ f"> searching {self.provider.display_name} for {cfg.compute.gpu.count}x "
405
+ f">={cfg.compute.gpu.min_vram_gb}GB GPU, {cfg.compute.disk_gb}GB disk"
406
+ )
407
+ estimated = self._check_budget(spec, allow_cost_override)
408
+ if estimated:
409
+ self.echo(f" cheapest match: ${estimated:.3f}/h")
410
+
411
+ instance: Optional[Instance] = None
412
+ try:
413
+ instance = self.provider.setup(spec)
414
+ self.echo(
415
+ f"+ instance {instance.id} created "
416
+ f"({instance.gpu_count or cfg.compute.gpu.count}x "
417
+ f"{instance.gpu_name or 'GPU'}"
418
+ + (
419
+ f", ${instance.hourly_cost_usd:.3f}/h)"
420
+ if instance.hourly_cost_usd
421
+ else ")"
422
+ )
423
+ )
424
+
425
+ state.provider = self.provider.name
426
+ state.instance_id = instance.id
427
+ state.instance = instance
428
+ state.fingerprint = fingerprint
429
+ state.model_id = cfg.model.id
430
+ state.created_at = instance.created_at or utc_now()
431
+ state.last_activity = utc_now()
432
+ state.starting = True
433
+ state.state = InstanceState.PROVISIONING.value
434
+ state.hourly_cost_usd = instance.hourly_cost_usd
435
+ state.locked = False
436
+ state.locked_until = None
437
+ # Billing starts the moment the provider creates the instance,
438
+ # not at the next reconcile - start the clock now so a long
439
+ # provisioning/startup wait is never undercounted.
440
+ state.billed_seconds = 0.0
441
+ state.running_since = state.created_at
442
+ state.watchdog = WatchdogInfo(
443
+ installed=cfg.lifecycle.watchdog.enabled,
444
+ token=token,
445
+ port=cfg.lifecycle.watchdog.port,
446
+ url=instance.endpoint_url("watchdog"),
447
+ )
448
+ # Persist before the long wait: a crash here must still leave a
449
+ # destroyable record (spec criterion 4).
450
+ self.store.save(state)
451
+ self.events.emit(
452
+ ev.INSTANCE_CREATED,
453
+ instance_id=instance.id,
454
+ gpu_name=instance.gpu_name,
455
+ gpu_count=instance.gpu_count,
456
+ hourly_cost_usd=instance.hourly_cost_usd,
457
+ )
458
+
459
+ if not wait:
460
+ return state
461
+ return self.ensure_ready(state=state)
462
+
463
+ except JambuError as exc:
464
+ self.events.emit(
465
+ ev.SETUP_FAILED, error=str(exc), error_type=type(exc).__name__
466
+ )
467
+ if cfg.lifecycle.cleanup_on_setup_failure and state.instance_id:
468
+ self.echo(f"! setup failed, cleaning up instance {state.instance_id}")
469
+ try:
470
+ self._terminate(
471
+ state, action=policy.ACTION_DESTROY, reason="setup_failure"
472
+ )
473
+ self.events.emit(
474
+ ev.CLEANUP_PERFORMED, instance_id=state.instance_id
475
+ )
476
+ except JambuError as cleanup_exc:
477
+ self.events.emit(
478
+ ev.PROVIDER_ERROR, operation="cleanup", error=str(cleanup_exc)
479
+ )
480
+ self.echo(f"! cleanup failed: {cleanup_exc}")
481
+ self.store.save(state)
482
+ raise
483
+
484
+ # ------------------------------------------------------------------
485
+ # readiness
486
+ # ------------------------------------------------------------------
487
+
488
+ def ensure_ready(self, state: Optional[RuntimeState] = None) -> RuntimeState:
489
+ """Guarantee the configured runtime is available (spec section 16)."""
490
+ with self.store.process_lock():
491
+ state = state if state is not None else self._reconcile(self.store.load())
492
+
493
+ if not state.has_instance():
494
+ self.store.save(state)
495
+ return self.setup()
496
+
497
+ instance = state.instance
498
+ if instance is None:
499
+ raise StateError("state has an instance id but no instance record")
500
+
501
+ if state.state == InstanceState.STOPPED.value:
502
+ if not self.provider.capabilities.stop:
503
+ raise UnsupportedCapabilityError(
504
+ f"provider '{self.provider.name}' cannot restart instances"
505
+ )
506
+ self.echo(f"> starting instance {instance.id}")
507
+ instance = self.provider.start(instance)
508
+ state.instance = instance
509
+ state.starting = True
510
+ state.mark_billable(True) # billing resumes now, not at the next reconcile
511
+ self.events.emit(ev.INSTANCE_STARTED, instance_id=instance.id)
512
+
513
+ instance = self._wait_running(state)
514
+ state.instance = instance
515
+ state.endpoint = instance.endpoint_url(ENDPOINT_NAME)
516
+ watchdog_url = instance.endpoint_url("watchdog")
517
+ if watchdog_url:
518
+ state.watchdog.url = watchdog_url
519
+ self.store.save(state)
520
+
521
+ self._connect_watchdog(state)
522
+
523
+ if self.config.runtime.engine != "none":
524
+ self._wait_runtime(state)
525
+
526
+ state.starting = False
527
+ state.state = InstanceState.READY.value
528
+ state.touch()
529
+ self.store.save(state)
530
+ return state
531
+
532
+ def _wait_running(self, state: RuntimeState) -> Instance:
533
+ instance = state.instance
534
+ assert instance is not None
535
+ deadline = time.time() + self.config.startup_timeout_s
536
+ announced = False
537
+ while time.time() < deadline:
538
+ status = self.provider.status(instance)
539
+ if not status.exists:
540
+ state.clear_instance()
541
+ self.store.save(state)
542
+ raise RuntimeStartupError(
543
+ f"instance {instance.id} vanished at the provider while starting"
544
+ )
545
+ current = status.instance or instance
546
+ if status.state is InstanceState.FAILED:
547
+ raise RuntimeStartupError(
548
+ f"instance {instance.id} failed: {status.message or 'no detail'}"
549
+ )
550
+ ready_ports = self._ports()
551
+ have_ports = all(
552
+ current.endpoints.get(name) is not None
553
+ for name in self._endpoint_names()
554
+ )
555
+ if status.state in (
556
+ InstanceState.READY,
557
+ InstanceState.BUSY,
558
+ InstanceState.IDLE,
559
+ ) and (have_ports or not ready_ports):
560
+ return current
561
+ if not announced:
562
+ announced = True
563
+ self.echo(" waiting for the machine to come up ...")
564
+ time.sleep(6)
565
+ raise RuntimeStartupError(
566
+ f"instance {instance.id} was not running after "
567
+ f"{int(self.config.startup_timeout_s)}s"
568
+ )
569
+
570
+ def _endpoint_names(self) -> list[str]:
571
+ names = []
572
+ if self.config.runtime.engine != "none":
573
+ names.append(ENDPOINT_NAME)
574
+ if self.config.lifecycle.watchdog.enabled:
575
+ names.append("watchdog")
576
+ return names
577
+
578
+ def _connect_watchdog(self, state: RuntimeState) -> None:
579
+ if not self.config.lifecycle.watchdog.enabled:
580
+ return
581
+ client = self.beacon(state)
582
+ if client is None:
583
+ state.watchdog.last_error = "no watchdog endpoint published"
584
+ self.echo("! watchdog port was not published; lifecycle guard is local-only")
585
+ return
586
+
587
+ deadline = time.time() + 90
588
+ while time.time() < deadline:
589
+ if client.ping():
590
+ client.identify(state.instance_id or "", state.hourly_cost_usd)
591
+ client.mark_starting()
592
+ state.watchdog.installed = True
593
+ state.watchdog.last_contact = utc_now()
594
+ state.watchdog.last_error = None
595
+ self.echo(f"+ lifecycle watchdog live at {state.watchdog.url}")
596
+ self.store.save(state)
597
+ return
598
+ time.sleep(5)
599
+
600
+ state.watchdog.last_error = "did not answer within 90s"
601
+ self.echo(
602
+ "! the remote watchdog never answered. The instance is NOT protected "
603
+ "by an independent guard - check `gpu logs`, and run "
604
+ "`gpu destroy` if this is unexpected."
605
+ )
606
+
607
+ def _wait_runtime(self, state: RuntimeState) -> None:
608
+ endpoint = state.endpoint
609
+ if not endpoint:
610
+ raise RuntimeStartupError(
611
+ "the model port was never published by the provider; cannot reach the runtime"
612
+ )
613
+ url = self.runtime.health_url(endpoint)
614
+ self.events.emit(
615
+ ev.RUNTIME_STARTING, endpoint=endpoint, engine=self.config.runtime.engine
616
+ )
617
+ self.echo(f"> waiting for {self.runtime.describe()} at {endpoint}")
618
+ started = time.time()
619
+
620
+ def _progress(attempt: int, remaining: float, detail: str) -> None:
621
+ if attempt % 4 == 0:
622
+ self.echo(
623
+ f" still starting ({int(time.time() - started)}s elapsed, "
624
+ f"{int(remaining)}s left, last probe: {detail})"
625
+ )
626
+
627
+ def _abort() -> Optional[str]:
628
+ instance = state.instance
629
+ if instance is None:
630
+ return None
631
+ try:
632
+ status = self.provider.status(instance)
633
+ except ProviderError:
634
+ return None
635
+ if not status.exists:
636
+ return "the instance disappeared at the provider"
637
+ if status.state is InstanceState.FAILED:
638
+ return status.message or "the instance entered a failed state"
639
+ if status.state is InstanceState.STOPPED:
640
+ return "the instance was stopped"
641
+ return None
642
+
643
+ wait_for_healthy(
644
+ url,
645
+ timeout=self.config.startup_timeout_s,
646
+ interval=self.config.health_interval_s,
647
+ on_attempt=_progress,
648
+ should_abort=_abort,
649
+ )
650
+ client = self.beacon(state)
651
+ if client is not None:
652
+ client.mark_ready()
653
+ self.events.emit(
654
+ ev.RUNTIME_READY,
655
+ endpoint=endpoint,
656
+ startup_seconds=round(time.time() - started, 1),
657
+ )
658
+ self.echo(f"+ runtime ready: {endpoint}")
659
+
660
+ # ------------------------------------------------------------------
661
+ # run (spec section 16)
662
+ # ------------------------------------------------------------------
663
+
664
+ def workload_env(self, state: RuntimeState, workload_id: str) -> dict[str, str]:
665
+ endpoint = state.endpoint or ""
666
+ env: dict[str, str] = {
667
+ "JAMBU_PROVIDER": self.provider.name,
668
+ "JAMBU_INSTANCE_ID": state.instance_id or "",
669
+ "JAMBU_WORKLOAD_ID": workload_id,
670
+ "JAMBU_MODEL_ID": self.config.model.id,
671
+ }
672
+ if endpoint:
673
+ env["JAMBU_GPU_ENDPOINT"] = endpoint
674
+ env["OPENAI_BASE_URL"] = f"{endpoint.rstrip('/')}/v1"
675
+ env["OPENAI_API_KEY"] = self.config.runtime.api_key or "jambu-local"
676
+ if state.instance and state.instance.ssh_host:
677
+ env["JAMBU_SSH_HOST"] = state.instance.ssh_host
678
+ env["JAMBU_SSH_PORT"] = str(state.instance.ssh_port or 22)
679
+ env.update({k: str(v) for k, v in self.config.workload.env.items()})
680
+ return env
681
+
682
+ def run(self, command: Sequence[str], label: str = "") -> int:
683
+ if not command:
684
+ raise ConfigError("nothing to run: pass a command, e.g. `gpu run python x.py`")
685
+
686
+ state = self.ensure_ready()
687
+ manager = ExecutionManager(
688
+ self.store,
689
+ self.events,
690
+ beacon=self.beacon(state),
691
+ heartbeat_interval=self.config.heartbeat_interval_s,
692
+ )
693
+ record = manager.register(command, label=label)
694
+ workload = Workload(
695
+ id=record.id,
696
+ command=list(command),
697
+ label=record.label,
698
+ cwd=self.config.workload.workdir or str(self.config.project_dir),
699
+ env=self.workload_env(state, record.id),
700
+ mode=self.config.workload.mode,
701
+ )
702
+
703
+ returncode: Optional[int] = None
704
+ try:
705
+ with Heartbeat(
706
+ record.id,
707
+ self.config.heartbeat_interval_s,
708
+ self.store,
709
+ self.beacon(state),
710
+ ):
711
+ if workload.mode == "remote":
712
+ self.provider.require_capability("remote_exec")
713
+ execution = self.provider.run(state.instance, workload)
714
+ else:
715
+ execution = manager.run_local(workload, record)
716
+ returncode = execution.returncode
717
+ finally:
718
+ manager.finish(record, returncode)
719
+
720
+ if returncode:
721
+ raise WorkloadFailed(
722
+ f"workload exited with code {returncode}", returncode=returncode
723
+ )
724
+ return 0
725
+
726
+ # ------------------------------------------------------------------
727
+ # status (spec section 18)
728
+ # ------------------------------------------------------------------
729
+
730
+ def status(self) -> dict[str, Any]:
731
+ with self.store.process_lock():
732
+ state = self._reconcile(self.store.load())
733
+ self.store.save(state)
734
+
735
+ cfg = self.policy_config(state.hourly_cost_usd)
736
+ snap = self.snapshot(state)
737
+ decision = policy.evaluate(snap, cfg)
738
+ remote = None
739
+ client = self.beacon(state)
740
+ if client is not None:
741
+ remote = client.try_state()
742
+
743
+ return {
744
+ "provider": self.provider.name,
745
+ "instance_id": state.instance_id,
746
+ "state": state.state if state.instance_id else "none",
747
+ "model": state.model_id or self.config.model.id,
748
+ "endpoint": state.endpoint,
749
+ "created_at": state.created_at,
750
+ "age_seconds": snap.age_seconds() if state.created_at else None,
751
+ "idle_seconds": snap.idle_seconds(cfg),
752
+ "locked": snap.lock_active(cfg),
753
+ "locked_until": state.locked_until,
754
+ "hourly_cost_usd": state.hourly_cost_usd,
755
+ "session_cost_usd": state.session_cost_usd,
756
+ "workloads": [
757
+ {
758
+ "id": w.id,
759
+ "label": w.label,
760
+ "state": w.state,
761
+ "started_at": w.started_at,
762
+ "last_heartbeat": w.last_heartbeat,
763
+ "returncode": w.returncode,
764
+ }
765
+ for w in state.workloads.values()
766
+ ],
767
+ "decision": decision.to_dict(),
768
+ "policy": cfg.to_dict(),
769
+ "watchdog": {
770
+ "enabled": self.config.lifecycle.watchdog.enabled,
771
+ "installed": state.watchdog.installed,
772
+ "url": state.watchdog.url,
773
+ "reachable": remote is not None,
774
+ "last_error": state.watchdog.last_error,
775
+ "remote_state": remote,
776
+ },
777
+ "capabilities": self.provider.capabilities.__dict__,
778
+ }
779
+
780
+ # ------------------------------------------------------------------
781
+ # lock / unlock (spec section 11)
782
+ # ------------------------------------------------------------------
783
+
784
+ def lock(self, ttl_seconds: Optional[float], reason: str = "") -> dict[str, Any]:
785
+ if ttl_seconds is None and not self.config.lifecycle.allow_indefinite_lock:
786
+ raise ConfigError(
787
+ "indefinite locks are disabled. Pass `--for 2h`, or set "
788
+ "lifecycle.allow_indefinite_lock: true in jambu.yaml."
789
+ )
790
+ with self.store.process_lock():
791
+ state = self._reconcile(self.store.load())
792
+ if not state.has_instance():
793
+ raise StateError("no instance to lock")
794
+ until = utc_now() + ttl_seconds if ttl_seconds else None
795
+ client = self.beacon(state)
796
+ if client is not None and client.try_request(
797
+ "POST", "/lock", {"ttl_seconds": ttl_seconds, "reason": reason}
798
+ ) is None:
799
+ state.watchdog.last_error = "unreachable"
800
+ self.echo(
801
+ "! the remote watchdog did not accept the lock; it may still stop "
802
+ "the instance. Check `gpu status`."
803
+ )
804
+ state.locked = True
805
+ state.locked_until = until
806
+ state.lock_reason = reason
807
+ state.touch()
808
+ self.store.save(state)
809
+ self.events.emit(
810
+ ev.INSTANCE_LOCKED,
811
+ instance_id=state.instance_id,
812
+ locked_until=until,
813
+ reason=reason,
814
+ )
815
+ return {"locked": True, "locked_until": until}
816
+
817
+ def unlock(self, reason: str = "manual") -> dict[str, Any]:
818
+ with self.store.process_lock():
819
+ state = self._reconcile(self.store.load())
820
+ client = self.beacon(state)
821
+ if client is not None:
822
+ client.try_request("POST", "/unlock", {"reason": reason})
823
+ state.locked = False
824
+ state.locked_until = None
825
+ state.lock_reason = ""
826
+ state.touch()
827
+ self.store.save(state)
828
+ self.events.emit(ev.INSTANCE_UNLOCKED, instance_id=state.instance_id, reason=reason)
829
+ return {"locked": False}
830
+
831
+ # ------------------------------------------------------------------
832
+ # stop / destroy
833
+ # ------------------------------------------------------------------
834
+
835
+ def stop(self, reason: str = "manual") -> dict[str, Any]:
836
+ with self.store.process_lock():
837
+ state = self._reconcile(self.store.load())
838
+ if not state.has_instance():
839
+ self.echo("= nothing to stop")
840
+ return {"changed": False, "state": state.state}
841
+ if state.state == InstanceState.STOPPED.value:
842
+ self.echo(f"= instance {state.instance_id} is already stopped")
843
+ return {"changed": False, "state": state.state}
844
+ action = (
845
+ policy.ACTION_STOP if self.provider.capabilities.stop else policy.ACTION_DESTROY
846
+ )
847
+ self._terminate(state, action=action, reason=reason)
848
+ self.store.save(state)
849
+ return {"changed": True, "state": state.state, "action": action}
850
+
851
+ def destroy(self, reason: str = "manual") -> dict[str, Any]:
852
+ with self.store.process_lock():
853
+ state = self._reconcile(self.store.load())
854
+ if not state.instance_id:
855
+ self.echo("= nothing to destroy")
856
+ return {"changed": False, "state": state.state}
857
+ self._terminate(state, action=policy.ACTION_DESTROY, reason=reason)
858
+ self.store.save(state)
859
+ return {"changed": True, "state": state.state}
860
+
861
+ def _terminate(self, state: RuntimeState, action: str, reason: str) -> None:
862
+ instance = state.instance or (
863
+ Instance(id=state.instance_id, provider=self.provider.name)
864
+ if state.instance_id
865
+ else None
866
+ )
867
+ if instance is None:
868
+ return
869
+ instance_id = instance.id
870
+ try:
871
+ if action == policy.ACTION_DESTROY:
872
+ self.provider.destroy(instance)
873
+ else:
874
+ self.provider.stop(instance)
875
+ except ProviderError as exc:
876
+ self.events.emit(
877
+ ev.PROVIDER_ERROR, operation=action, instance_id=instance_id, error=str(exc)
878
+ )
879
+ raise
880
+
881
+ if action == policy.ACTION_DESTROY:
882
+ state.clear_instance(InstanceState.DESTROYED.value)
883
+ self.events.emit(ev.INSTANCE_DESTROYED, instance_id=instance_id, reason=reason)
884
+ self.echo(f"- instance {instance_id} destroyed ({reason})")
885
+ else:
886
+ state.state = InstanceState.STOPPED.value
887
+ state.starting = False
888
+ state.mark_billable(False)
889
+ if state.hourly_cost_usd:
890
+ state.session_cost_usd = round(
891
+ state.hourly_cost_usd * state.accrued_billable_seconds() / 3600.0, 4
892
+ )
893
+ for record in state.workloads.values():
894
+ if record.state in ("running", "queued"):
895
+ record.state = "failed"
896
+ record.finished_at = utc_now()
897
+ self.events.emit(ev.INSTANCE_STOPPED, instance_id=instance_id, reason=reason)
898
+ self.echo(f"- instance {instance_id} stopped ({reason})")
899
+
900
+ # ------------------------------------------------------------------
901
+ # guard (external controller path, spec section 14 alternative)
902
+ # ------------------------------------------------------------------
903
+
904
+ def guard_tick(self, apply: bool = True) -> dict[str, Any]:
905
+ """Evaluate the lifecycle rule locally and optionally enforce it.
906
+
907
+ The remote watchdog does this on the instance. This entry point exists
908
+ for a scheduled external controller (CI job, cron) and for `status`.
909
+ """
910
+ with self.store.process_lock():
911
+ state = self._reconcile(self.store.load())
912
+ if not state.has_instance():
913
+ self.store.save(state)
914
+ return {"action": policy.ACTION_NONE, "reason": "no_instance"}
915
+
916
+ cfg = self.policy_config(state.hourly_cost_usd)
917
+ snap = self.snapshot(state)
918
+
919
+ if state.locked and snap.lock_expired(cfg):
920
+ state.locked = False
921
+ state.locked_until = None
922
+ self.events.emit(ev.INSTANCE_UNLOCKED, reason="lock_expired")
923
+ snap = self.snapshot(state)
924
+
925
+ decision = policy.evaluate(snap, cfg)
926
+ if decision.terminates and apply:
927
+ self.events.emit(
928
+ ev.LIFECYCLE_GUARD_TRIGGERED,
929
+ instance_id=state.instance_id,
930
+ action=decision.action,
931
+ reason=decision.reason,
932
+ **decision.detail,
933
+ )
934
+ self._terminate(state, action=decision.action, reason=decision.reason or "guard")
935
+ self.store.save(state)
936
+ return decision.to_dict()
937
+
938
+ # ------------------------------------------------------------------
939
+ # logs
940
+ # ------------------------------------------------------------------
941
+
942
+ def logs(self, limit: int = 200) -> list[str]:
943
+ with self.store.process_lock():
944
+ state = self._reconcile(self.store.load())
945
+ self.store.save(state)
946
+ if not state.instance:
947
+ raise StateError("no instance to read logs from")
948
+ self.provider.require_capability("logs")
949
+ stream = self.provider.logs(state.instance)
950
+ lines = list(stream)
951
+ return lines[-limit:]