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,959 @@
1
+ """Remote lifecycle watchdog (spec section 14).
2
+
3
+ This file is NOT imported at runtime by the CLI. Its source is concatenated
4
+ with ``core/policy.py`` and the provider-supplied action source into a single
5
+ stdlib-only script that runs *on the rented instance*, so lifecycle
6
+ enforcement survives the local CLI crashing, the laptop sleeping or the
7
+ network dying.
8
+
9
+ Contract with the CLI (all routes require ``X-Jambu-Token``):
10
+ GET /health -> liveness
11
+ GET /state -> snapshot + current policy decision
12
+ GET /events?limit=N -> structured events recorded on the instance
13
+ GET /logs?name=X&lines=N -> tail of a configured log (runtime|onstart|watchdog)
14
+ POST /activity -> generic activity mark
15
+ POST /ready -> model server finished starting
16
+ POST /workload/start -> {id, label}
17
+ POST /workload/heartbeat -> {id}
18
+ POST /workload/end -> {id, returncode, state}
19
+ POST /lock -> {ttl_seconds|until|null}
20
+ POST /unlock
21
+ POST /shutdown -> {action: stop|destroy, reason}
22
+
23
+ Sessions: the boot script exports ``JAMBU_SESSION_STARTED_AT`` once per
24
+ container start. A value different from the stored one means the instance was
25
+ restarted, so the persisted "terminated" flag and stale workloads are reset and
26
+ the guard starts over. The same value means a supervisor respawn: keep state.
27
+ """
28
+
29
+ # The generated script inlines core/policy.py above this body. When this file
30
+ # is imported directly (tests, linting) we pull the same names from the package.
31
+ if "evaluate" not in globals(): # pragma: no cover - only in standalone import
32
+ from gpu4agents.core.policy import ( # noqa: F401
33
+ ACTION_DESTROY,
34
+ ACTION_NONE,
35
+ ACTION_STOP,
36
+ Decision,
37
+ PolicyConfig,
38
+ Snapshot,
39
+ Workload,
40
+ derive_state,
41
+ evaluate,
42
+ )
43
+
44
+ import hmac
45
+ import json
46
+ import math
47
+ import os
48
+ import sys
49
+ import threading
50
+ import time
51
+ import traceback
52
+ import urllib.request
53
+ from urllib.parse import parse_qs
54
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
55
+
56
+ CONFIG_PATH = os.environ.get("JAMBU_WATCHDOG_CONFIG", "/etc/jambu/watchdog.json")
57
+ STATE_PATH = os.environ.get("JAMBU_WATCHDOG_STATE", "/var/lib/jambu/activity.json")
58
+ EVENTS_PATH = os.environ.get("JAMBU_WATCHDOG_EVENTS", "/var/log/jambu/events.jsonl")
59
+
60
+ _LOCK = threading.RLock()
61
+ _STATE = {}
62
+ _CONFIG = {}
63
+ _STOPPED = threading.Event()
64
+ # Serializes provider calls; the guard loop and /shutdown may race.
65
+ _ACTION_LOCK = threading.Lock()
66
+ _PROBE = {}
67
+
68
+ # Seconds of continuously failing stops before giving up on the disk and
69
+ # destroying (config key escalate_after_s). Long enough to ride out a provider
70
+ # API outage: losing the disk to a 3-minute blip is worse than 30 more minutes.
71
+ ESCALATE_AFTER_S = 1800.0
72
+ PROBE_ERROR_LOG_EVERY_S = 600.0
73
+ LOG_LINES_MAX = 2000
74
+
75
+
76
+ # --------------------------------------------------------------------------
77
+ # persistence
78
+ # --------------------------------------------------------------------------
79
+
80
+
81
+ def _atomic_write(path, payload):
82
+ directory = os.path.dirname(path) or "."
83
+ try:
84
+ os.makedirs(directory, exist_ok=True)
85
+ except OSError:
86
+ pass
87
+ tmp = path + ".tmp"
88
+ with open(tmp, "w") as handle:
89
+ handle.write(payload)
90
+ handle.flush()
91
+ os.fsync(handle.fileno())
92
+ os.replace(tmp, path)
93
+
94
+
95
+ def log_event(event, **fields):
96
+ record = {
97
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
98
+ "event": event,
99
+ "source": "watchdog",
100
+ "provider": _CONFIG.get("provider"),
101
+ "instance_id": (_STATE or {}).get("instance_id") or _CONFIG.get("instance_id"),
102
+ }
103
+ record.update(fields)
104
+ line = json.dumps(record, default=str)
105
+ try:
106
+ os.makedirs(os.path.dirname(EVENTS_PATH) or ".", exist_ok=True)
107
+ with open(EVENTS_PATH, "a") as handle:
108
+ handle.write(line + "\n")
109
+ except OSError:
110
+ pass
111
+ print("[jambu-watchdog] " + line, flush=True)
112
+ return record
113
+
114
+
115
+ def _session_from_env():
116
+ raw = os.environ.get("JAMBU_SESSION_STARTED_AT")
117
+ if not raw:
118
+ return None
119
+ try:
120
+ return float(raw)
121
+ except ValueError:
122
+ return None
123
+
124
+
125
+ def load_state():
126
+ now = time.time()
127
+ default = {
128
+ "created_at": float(_CONFIG.get("created_at") or now),
129
+ "last_activity": now,
130
+ "starting": True,
131
+ "locked": False,
132
+ "locked_until": None,
133
+ "workloads": {},
134
+ "terminated": False,
135
+ "terminated_at": None,
136
+ "stop_failures": 0,
137
+ "action_failures": 0,
138
+ "pending_shutdown": None,
139
+ "session_started_at": None,
140
+ }
141
+ stored = None
142
+ try:
143
+ with open(STATE_PATH) as handle:
144
+ stored = json.load(handle)
145
+ default.update(stored)
146
+ except (OSError, ValueError):
147
+ stored = None
148
+
149
+ session = _session_from_env()
150
+ if stored is None:
151
+ default["session_started_at"] = session if session is not None else now
152
+ log_event("session_started", session_started_at=default["session_started_at"], first_boot=True)
153
+ return default
154
+ if session is None:
155
+ # Old boot scripts and tests: no session marker, behave as before.
156
+ if default.get("session_started_at") is None:
157
+ default["session_started_at"] = default["created_at"]
158
+ return default
159
+ if default.get("session_started_at") == session:
160
+ return default # supervisor respawn inside the same container start
161
+ return _new_session(default, session, now)
162
+
163
+
164
+ def _new_session(state, session, now):
165
+ """The container was started again (e.g. `gpu` restarted a stopped instance).
166
+
167
+ Container disk survives a stop, so the previous session's "terminated" flag
168
+ would otherwise disarm the guard on the restarted, billing machine.
169
+ """
170
+ abandoned = []
171
+ for key, record in (state.get("workloads") or {}).items():
172
+ if record.get("state") in ("running", "queued"):
173
+ record["state"] = "failed"
174
+ record["finished_at"] = now
175
+ record["failure"] = "session_restarted"
176
+ abandoned.append(key)
177
+ previous = state.get("session_started_at")
178
+ state.update(
179
+ {
180
+ "session_started_at": session,
181
+ "terminated": False,
182
+ "terminated_at": None,
183
+ "starting": True,
184
+ "last_activity": now,
185
+ "stop_failures": 0,
186
+ "stop_failing_since": None,
187
+ "action_failures": 0,
188
+ "pending_shutdown": None,
189
+ }
190
+ )
191
+ log_event(
192
+ "session_started",
193
+ session_started_at=session,
194
+ previous_session_started_at=previous,
195
+ abandoned_workloads=abandoned,
196
+ )
197
+ return state
198
+
199
+
200
+ def save_state():
201
+ try:
202
+ _atomic_write(STATE_PATH, json.dumps(_STATE, indent=2, default=str))
203
+ except OSError:
204
+ pass
205
+
206
+
207
+ def touch(reason="activity"):
208
+ with _LOCK:
209
+ _STATE["last_activity"] = time.time()
210
+ save_state()
211
+ return reason
212
+
213
+
214
+ # --------------------------------------------------------------------------
215
+ # policy wiring
216
+ # --------------------------------------------------------------------------
217
+
218
+
219
+ def policy_config():
220
+ merged = dict(_CONFIG.get("policy") or {})
221
+ merged.update(_STATE.get("policy_overrides") or {})
222
+ return PolicyConfig.from_dict(merged)
223
+
224
+
225
+ def snapshot(now=None):
226
+ now = now if now is not None else time.time()
227
+ with _LOCK:
228
+ workloads = [
229
+ Workload(
230
+ id=key,
231
+ started_at=float(value.get("started_at") or now),
232
+ last_heartbeat=float(value.get("last_heartbeat") or now),
233
+ label=value.get("label", ""),
234
+ state=value.get("state", "running"),
235
+ )
236
+ for key, value in (_STATE.get("workloads") or {}).items()
237
+ ]
238
+ starting = bool(_STATE.get("starting"))
239
+ if starting and runtime_alive() is False:
240
+ # A dead model server must not hold the startup grace open.
241
+ starting = False
242
+ _STATE["starting"] = False
243
+ log_event("runtime_exited", pid_path=_CONFIG.get("runtime_pid_path"))
244
+ save_state()
245
+ if starting:
246
+ grace = float(_CONFIG.get("startup_grace_s") or 900.0)
247
+ began = _STATE.get("session_started_at") or _STATE.get("created_at") or now
248
+ if now - float(began) > grace:
249
+ # The CLI never confirmed readiness; stop pretending we booted.
250
+ starting = False
251
+ _STATE["starting"] = False
252
+ log_event("runtime_startup_grace_expired", grace_seconds=grace)
253
+ save_state()
254
+ return Snapshot(
255
+ now=now,
256
+ created_at=float(_STATE.get("created_at") or now),
257
+ last_activity=float(_STATE.get("last_activity") or now),
258
+ workloads=workloads,
259
+ starting=starting,
260
+ locked=bool(_STATE.get("locked")),
261
+ locked_until=_STATE.get("locked_until"),
262
+ provider_state="ready",
263
+ session_started_at=_STATE.get("session_started_at"),
264
+ )
265
+
266
+
267
+ def runtime_alive():
268
+ """True/False for the model server process; None until it wrote a pid file.
269
+
270
+ A pid file older than this container start is a previous session's.
271
+ """
272
+ path = _CONFIG.get("runtime_pid_path")
273
+ if not path:
274
+ return None
275
+ try:
276
+ if os.stat(path).st_mtime < float(_STATE.get("session_started_at") or 0) - 5:
277
+ return None
278
+ with open(path) as handle:
279
+ pid = int(handle.read().strip())
280
+ except (OSError, ValueError):
281
+ return None
282
+ try:
283
+ os.kill(pid, 0)
284
+ except ProcessLookupError:
285
+ return False
286
+ except OSError:
287
+ return True
288
+ try:
289
+ with open("/proc/%d/stat" % pid) as handle:
290
+ return handle.read().rsplit(")", 1)[1].split()[0] not in ("Z", "X")
291
+ except (OSError, IndexError):
292
+ return True
293
+
294
+
295
+ def tail_lines(path, count):
296
+ """The last ``count`` lines of ``path``, reading backwards from its end."""
297
+ try:
298
+ with open(path, "rb") as handle:
299
+ pos = handle.seek(0, 2)
300
+ data = b""
301
+ while pos > 0 and data.count(b"\n") <= count:
302
+ step = min(65536, pos)
303
+ pos -= step
304
+ handle.seek(pos)
305
+ data = handle.read(step) + data
306
+ except OSError:
307
+ return []
308
+ return data.decode("utf-8", "replace").splitlines()[-count:] if count > 0 else []
309
+
310
+
311
+ def build_context():
312
+ ctx = dict(_CONFIG.get("provider_context") or {})
313
+ ctx["instance_id"] = _STATE.get("instance_id") or _CONFIG.get("instance_id")
314
+ return ctx
315
+
316
+
317
+ def terminate(action, reason, detail=None, force=False):
318
+ """Ask the provider to stop or destroy this machine.
319
+
320
+ Returns True on success, False on a provider failure and None when skipped
321
+ (another call in flight, or already terminated and not ``force``). A
322
+ failure never disarms the guard: the loop retries on its next tick.
323
+ """
324
+ detail = detail or {}
325
+ if not _ACTION_LOCK.acquire(blocking=False):
326
+ return None
327
+ try:
328
+ if _STATE.get("terminated") and not force:
329
+ return None
330
+ log_event(
331
+ "lifecycle_guard_triggered",
332
+ action=action,
333
+ reason=reason,
334
+ **detail,
335
+ )
336
+ ctx = build_context()
337
+ try:
338
+ if action == ACTION_DESTROY:
339
+ provider_destroy(ctx) # noqa: F821 - injected by the builder
340
+ log_event("instance_destroyed", reason=reason, **detail)
341
+ else:
342
+ provider_stop(ctx) # noqa: F821 - injected by the builder
343
+ log_event("instance_stopped", reason=reason, **detail)
344
+ except Exception as exc: # noqa: BLE001 - last line of defence
345
+ with _LOCK:
346
+ _STATE["action_failures"] = int(_STATE.get("action_failures") or 0) + 1
347
+ if action == ACTION_STOP:
348
+ _STATE["stop_failures"] = int(_STATE.get("stop_failures") or 0) + 1
349
+ _STATE["stop_failing_since"] = _STATE.get("stop_failing_since") or time.time()
350
+ save_state()
351
+ log_event(
352
+ "provider_error",
353
+ action=action,
354
+ reason=reason,
355
+ error=str(exc),
356
+ traceback=traceback.format_exc(limit=3),
357
+ )
358
+ return False
359
+ with _LOCK:
360
+ _STATE["terminated"] = True
361
+ _STATE["terminated_at"] = time.time()
362
+ _STATE["terminated_action"] = action
363
+ _STATE["terminated_reason"] = reason
364
+ _reset_failures()
365
+ # Done: a pending request must not be replayed after a restart.
366
+ _STATE["pending_shutdown"] = None
367
+ save_state()
368
+ return True
369
+ finally:
370
+ _ACTION_LOCK.release()
371
+
372
+
373
+ def reissue_after_s(interval):
374
+ configured = _CONFIG.get("reissue_after_s")
375
+ if configured is not None:
376
+ return float(configured)
377
+ return max(300.0, 5.0 * interval)
378
+
379
+
380
+ def _reset_failures():
381
+ _STATE["stop_failures"] = 0
382
+ _STATE["stop_failing_since"] = None
383
+ _STATE["action_failures"] = 0
384
+
385
+
386
+ def _choose(decision, cfg, now=None):
387
+ """(action, reason, pre-escalation reason) to attempt this tick, or None."""
388
+ action, reason = None, None
389
+ pending = _STATE.get("pending_shutdown")
390
+ if pending:
391
+ # An explicit request outranks whatever the policy would pick.
392
+ action = pending.get("action") or ACTION_STOP
393
+ reason = pending.get("reason") or "manual"
394
+ elif decision.terminates:
395
+ action, reason = decision.action, decision.reason
396
+ if action is None:
397
+ # Back in use: failures from an earlier idle spell must not count
398
+ # towards destroying the disk later.
399
+ if _STATE.get("stop_failures") or _STATE.get("action_failures"):
400
+ _reset_failures()
401
+ save_state()
402
+ return None
403
+ since = _STATE.get("stop_failing_since")
404
+ limit = float(_CONFIG.get("escalate_after_s") or ESCALATE_AFTER_S)
405
+ if (
406
+ action == ACTION_STOP
407
+ and since
408
+ and (now or time.time()) - float(since) >= limit
409
+ and cfg.destroy_supported
410
+ ):
411
+ # Billing forever is worse than losing the disk.
412
+ return ACTION_DESTROY, "stop_failed_escalation", reason
413
+ return action, reason, None
414
+
415
+
416
+ def guard_tick(interval, idle_announced):
417
+ cfg = policy_config()
418
+
419
+ with _LOCK:
420
+ if _STATE.get("terminated"):
421
+ since = float(_STATE.get("terminated_at") or 0)
422
+ if time.time() - since <= reissue_after_s(interval):
423
+ return idle_announced
424
+ # The provider accepted the call, yet this machine is still running.
425
+ _STATE["terminated"] = False
426
+ save_state()
427
+ log_event(
428
+ "guard_reissue",
429
+ previous_action=_STATE.get("terminated_action"),
430
+ seconds_since_terminate=round(time.time() - since, 1),
431
+ )
432
+
433
+ snap = snapshot()
434
+ with _LOCK:
435
+ if _STATE.get("locked") and snap.lock_expired(cfg):
436
+ _STATE["locked"] = False
437
+ _STATE["locked_until"] = None
438
+ save_state()
439
+ log_event("instance_unlocked", reason="lock_expired")
440
+
441
+ decision = evaluate(snap, cfg)
442
+ state = decision.detail.get("state")
443
+ if state == "idle" and not idle_announced:
444
+ idle_announced = True
445
+ log_event("instance_idle", **decision.detail)
446
+ elif state != "idle":
447
+ idle_announced = False
448
+
449
+ with _LOCK:
450
+ choice = _choose(decision, cfg)
451
+ if choice is None:
452
+ return idle_announced
453
+ action, reason, original = choice
454
+ detail = dict(decision.detail)
455
+ if original:
456
+ detail["original_reason"] = original
457
+ if terminate(action, reason, detail) is False:
458
+ log_event(
459
+ "guard_retry",
460
+ action=action,
461
+ reason=reason,
462
+ attempt=int(_STATE.get("action_failures") or 0),
463
+ stop_failures=int(_STATE.get("stop_failures") or 0),
464
+ )
465
+ return idle_announced
466
+
467
+
468
+ def guard_loop():
469
+ interval = float(_CONFIG.get("interval_s") or 60.0)
470
+ cfg = policy_config()
471
+ log_event(
472
+ "watchdog_started",
473
+ interval_seconds=interval,
474
+ idle_timeout_seconds=cfg.idle_timeout_s,
475
+ max_lifetime_seconds=cfg.max_lifetime_s,
476
+ auto_stop=cfg.auto_stop,
477
+ auto_destroy=cfg.auto_destroy,
478
+ session_started_at=_STATE.get("session_started_at"),
479
+ )
480
+ idle_announced = False
481
+ # Only process exit ends this loop: no decision or provider failure may.
482
+ while not _STOPPED.wait(interval):
483
+ try:
484
+ idle_announced = guard_tick(interval, idle_announced)
485
+ except Exception as exc: # noqa: BLE001 - never let the guard die
486
+ log_event("watchdog_error", error=str(exc), traceback=traceback.format_exc(limit=3))
487
+
488
+
489
+ # --------------------------------------------------------------------------
490
+ # inference traffic probe
491
+ # --------------------------------------------------------------------------
492
+
493
+
494
+ def _split_sample(line):
495
+ """``name{labels} value [timestamp]`` -> (name, value_text) or None."""
496
+ brace = line.find("{")
497
+ space = line.find(" ")
498
+ if brace != -1 and (space == -1 or brace < space):
499
+ name = line[:brace]
500
+ i, quoted = brace + 1, False
501
+ while i < len(line):
502
+ ch = line[i]
503
+ if quoted and ch == "\\":
504
+ i += 2
505
+ continue
506
+ if ch == '"':
507
+ quoted = not quoted
508
+ elif ch == "}" and not quoted:
509
+ break
510
+ i += 1
511
+ rest = line[i + 1 :].split()
512
+ else:
513
+ parts = line.split()
514
+ name, rest = (parts[0], parts[1:]) if parts else ("", [])
515
+ if not name or not rest:
516
+ return None
517
+ return name, rest[0]
518
+
519
+
520
+ def parse_prometheus(text):
521
+ """Prometheus text format -> {metric name: sum of samples across label sets}."""
522
+ totals = {}
523
+ for raw in text.splitlines():
524
+ line = raw.strip()
525
+ if not line or line.startswith("#"):
526
+ continue
527
+ sample = _split_sample(line)
528
+ if sample is None:
529
+ continue
530
+ name, value_text = sample
531
+ try:
532
+ value = float(value_text)
533
+ except ValueError:
534
+ continue
535
+ if math.isnan(value) or math.isinf(value):
536
+ continue
537
+ totals[name] = totals.get(name, 0.0) + value
538
+ return totals
539
+
540
+
541
+ def metric_value(totals, name):
542
+ """Look a metric up, tolerating exporters that add or drop ``_total``."""
543
+ if name in totals:
544
+ return totals[name]
545
+ alt = name[: -len("_total")] if name.endswith("_total") else name + "_total"
546
+ return totals.get(alt)
547
+
548
+
549
+ def traffic_seen(totals, previous, gauges, counters):
550
+ """True when a gauge is non-zero or a counter grew since ``previous``."""
551
+ for name in gauges:
552
+ value = metric_value(totals, name)
553
+ if value is not None and value > 0:
554
+ return True
555
+ if previous is None:
556
+ return False # the first read only establishes the baseline
557
+ for name in counters:
558
+ value = metric_value(totals, name)
559
+ before = metric_value(previous, name)
560
+ # A drop means the runtime restarted its counters: rebaseline, not traffic.
561
+ if value is not None and before is not None and value > before:
562
+ return True
563
+ return False
564
+
565
+
566
+ def probe_once(spec, previous):
567
+ """One scrape. Returns the new baseline (the old one on error)."""
568
+ now = time.time()
569
+ try:
570
+ with urllib.request.urlopen(spec["url"], timeout=5) as response:
571
+ text = response.read().decode("utf-8", "replace")
572
+ totals = parse_prometheus(text)
573
+ except Exception as exc: # noqa: BLE001 - a probe error is never activity
574
+ with _LOCK:
575
+ _PROBE.update({"ok": False, "at": now, "error": str(exc)})
576
+ last_logged = _PROBE.get("error_logged_at") or 0
577
+ if now - last_logged >= PROBE_ERROR_LOG_EVERY_S:
578
+ _PROBE["error_logged_at"] = now
579
+ log_event("activity_probe_error", url=spec.get("url"), error=str(exc))
580
+ return previous
581
+
582
+ gauges = list(spec.get("gauges") or [])
583
+ counters = list(spec.get("counters") or [])
584
+ traffic = traffic_seen(totals, previous, gauges, counters)
585
+ summary = {}
586
+ for name in gauges + counters:
587
+ value = metric_value(totals, name)
588
+ if value is not None:
589
+ summary[name] = value
590
+ with _LOCK:
591
+ _PROBE.update({"ok": True, "at": now, "error": None, "values": summary})
592
+ if traffic:
593
+ _PROBE["last_traffic_at"] = now
594
+ if traffic:
595
+ touch("inference_traffic")
596
+ return totals
597
+
598
+
599
+ def probe_loop(spec):
600
+ interval = float(_CONFIG.get("interval_s") or 60.0)
601
+ period = min(interval, 15.0)
602
+ previous = None
603
+ while not _STOPPED.is_set():
604
+ try:
605
+ previous = probe_once(spec, previous)
606
+ except Exception as exc: # noqa: BLE001
607
+ log_event("watchdog_error", where="activity_probe", error=str(exc))
608
+ _STOPPED.wait(period)
609
+
610
+
611
+ def probe_spec():
612
+ spec = _CONFIG.get("activity_probe")
613
+ if not isinstance(spec, dict) or not spec.get("url"):
614
+ return None
615
+ if (spec.get("kind") or "prometheus") != "prometheus":
616
+ return None
617
+ return spec
618
+
619
+
620
+ def probe_payload():
621
+ spec = probe_spec()
622
+ if spec is None:
623
+ return None
624
+ with _LOCK:
625
+ return {
626
+ "url": spec.get("url"),
627
+ "ok": _PROBE.get("ok"),
628
+ "at": _PROBE.get("at"),
629
+ "error": _PROBE.get("error"),
630
+ "values": dict(_PROBE.get("values") or {}),
631
+ "last_traffic_at": _PROBE.get("last_traffic_at"),
632
+ }
633
+
634
+
635
+ # --------------------------------------------------------------------------
636
+ # HTTP control surface
637
+ # --------------------------------------------------------------------------
638
+
639
+
640
+ class Handler(BaseHTTPRequestHandler):
641
+ server_version = "jambu-watchdog/1.0"
642
+
643
+ def log_message(self, fmt, *args): # noqa: A003 - silence access logs
644
+ return
645
+
646
+ # -- plumbing -------------------------------------------------------
647
+
648
+ def _authorized(self):
649
+ expected = _CONFIG.get("token")
650
+ if not expected:
651
+ return True
652
+ supplied = self.headers.get("X-Jambu-Token") or ""
653
+ if not hmac.compare_digest(supplied.encode(), str(expected).encode()):
654
+ self._reply(401, {"error": "unauthorized"})
655
+ return False
656
+ return True
657
+
658
+ def _reply(self, code, payload):
659
+ body = json.dumps(payload, default=str).encode()
660
+ self.send_response(code)
661
+ self.send_header("Content-Type", "application/json")
662
+ self.send_header("Content-Length", str(len(body)))
663
+ self.end_headers()
664
+ try:
665
+ self.wfile.write(body)
666
+ except OSError:
667
+ pass
668
+
669
+ def _payload(self):
670
+ try:
671
+ length = int(self.headers.get("Content-Length") or 0)
672
+ except ValueError:
673
+ length = 0
674
+ if not length:
675
+ return {}
676
+ try:
677
+ return json.loads(self.rfile.read(length) or b"{}")
678
+ except ValueError:
679
+ return {}
680
+
681
+ # -- routes ---------------------------------------------------------
682
+
683
+ def do_GET(self): # noqa: N802
684
+ path = self.path.split("?")[0].rstrip("/") or "/"
685
+ if path == "/health":
686
+ self._reply(200, {"ok": True, "service": "jambu-watchdog"})
687
+ return
688
+ if not self._authorized():
689
+ return
690
+ if path == "/state":
691
+ self._reply(200, state_payload())
692
+ return
693
+ if path == "/events":
694
+ limit = 100
695
+ if "limit=" in self.path:
696
+ try:
697
+ limit = int(self.path.split("limit=")[1].split("&")[0])
698
+ except ValueError:
699
+ limit = 100
700
+ self._reply(200, {"events": read_events(limit)})
701
+ return
702
+ if path == "/logs":
703
+ query = parse_qs(self.path.partition("?")[2])
704
+ name = (query.get("name") or ["runtime"])[0]
705
+ target = (_CONFIG.get("logs") or {}).get(name)
706
+ if not target:
707
+ self._reply(404, {"error": "unknown log %r" % name})
708
+ return
709
+ try:
710
+ count = int((query.get("lines") or [200])[0])
711
+ except ValueError:
712
+ count = 200
713
+ count = max(0, min(count, LOG_LINES_MAX))
714
+ self._reply(200, {"name": name, "lines": tail_lines(target, count)})
715
+ return
716
+ self._reply(404, {"error": "not found"})
717
+
718
+ def do_POST(self): # noqa: N802
719
+ path = self.path.split("?")[0].rstrip("/") or "/"
720
+ if not self._authorized():
721
+ return
722
+ body = self._payload()
723
+ try:
724
+ self._reply(*dispatch(path, body))
725
+ except Exception as exc: # noqa: BLE001
726
+ log_event("watchdog_error", route=path, error=str(exc))
727
+ self._reply(500, {"error": str(exc)})
728
+
729
+
730
+ def state_payload():
731
+ cfg = policy_config()
732
+ snap = snapshot()
733
+ decision = evaluate(snap, cfg)
734
+ with _LOCK:
735
+ workloads = json.loads(json.dumps(_STATE.get("workloads") or {}, default=str))
736
+ return {
737
+ "instance_id": _STATE.get("instance_id") or _CONFIG.get("instance_id"),
738
+ "provider": _CONFIG.get("provider"),
739
+ "created_at": _STATE.get("created_at"),
740
+ "last_activity": _STATE.get("last_activity"),
741
+ "starting": snap.starting,
742
+ "runtime_alive": runtime_alive(),
743
+ "locked": snap.lock_active(cfg),
744
+ "locked_until": _STATE.get("locked_until"),
745
+ "terminated": bool(_STATE.get("terminated")),
746
+ "session_started_at": _STATE.get("session_started_at"),
747
+ "session_age_seconds": round(snap.session_age_seconds(), 1),
748
+ "stop_failures": int(_STATE.get("stop_failures") or 0),
749
+ "pending_shutdown": _STATE.get("pending_shutdown"),
750
+ "last_probe": probe_payload(),
751
+ "state": derive_state(snap, cfg),
752
+ "idle_seconds": round(snap.idle_seconds(cfg), 1),
753
+ "age_seconds": round(snap.age_seconds(), 1),
754
+ "workloads": workloads,
755
+ "decision": decision.to_dict(),
756
+ "policy": cfg.to_dict(),
757
+ "now": time.time(),
758
+ }
759
+
760
+
761
+ def read_events(limit=100):
762
+ try:
763
+ with open(EVENTS_PATH) as handle:
764
+ lines = handle.read().splitlines()
765
+ except OSError:
766
+ return []
767
+ out = []
768
+ for line in lines[-limit:]:
769
+ try:
770
+ out.append(json.loads(line))
771
+ except ValueError:
772
+ continue
773
+ return out
774
+
775
+
776
+ def dispatch(path, body):
777
+ now = time.time()
778
+
779
+ if path == "/identify":
780
+ # The instance id only exists after creation, and the onstart script is
781
+ # baked before that; the CLI tells the watchdog who it is once it can.
782
+ with _LOCK:
783
+ if body.get("instance_id"):
784
+ _STATE["instance_id"] = str(body["instance_id"])
785
+ overrides = _STATE.setdefault("policy_overrides", {})
786
+ if body.get("hourly_cost_usd") is not None:
787
+ overrides["hourly_cost_usd"] = float(body["hourly_cost_usd"])
788
+ if body.get("policy"):
789
+ overrides.update(dict(body["policy"]))
790
+ _STATE["last_activity"] = now
791
+ save_state()
792
+ log_event("watchdog_identified", instance_id=_STATE.get("instance_id"))
793
+ return 200, {"ok": True, "instance_id": _STATE.get("instance_id")}
794
+
795
+ if path == "/activity":
796
+ touch()
797
+ return 200, {"ok": True, "last_activity": _STATE["last_activity"]}
798
+
799
+ if path == "/ready":
800
+ with _LOCK:
801
+ _STATE["starting"] = False
802
+ _STATE["last_activity"] = now
803
+ save_state()
804
+ log_event("runtime_ready")
805
+ return 200, {"ok": True}
806
+
807
+ if path == "/starting":
808
+ with _LOCK:
809
+ _STATE["starting"] = True
810
+ _STATE["created_at"] = _STATE.get("created_at") or now
811
+ _STATE["last_activity"] = now
812
+ save_state()
813
+ log_event("runtime_starting")
814
+ return 200, {"ok": True}
815
+
816
+ if path == "/workload/start":
817
+ workload_id = str(body.get("id") or "w-%d" % int(now * 1000))
818
+ with _LOCK:
819
+ _STATE.setdefault("workloads", {})[workload_id] = {
820
+ "started_at": now,
821
+ "last_heartbeat": now,
822
+ "label": body.get("label", ""),
823
+ "state": body.get("state", "running"),
824
+ }
825
+ _STATE["last_activity"] = now
826
+ _STATE["starting"] = False
827
+ save_state()
828
+ log_event("workload_started", workload_id=workload_id, label=body.get("label", ""))
829
+ return 200, {"ok": True, "id": workload_id}
830
+
831
+ if path == "/workload/heartbeat":
832
+ workload_id = body.get("id")
833
+ with _LOCK:
834
+ record = (_STATE.get("workloads") or {}).get(workload_id)
835
+ if record is None and workload_id:
836
+ record = {
837
+ "started_at": now,
838
+ "label": body.get("label", ""),
839
+ "state": "running",
840
+ }
841
+ _STATE.setdefault("workloads", {})[workload_id] = record
842
+ if record is not None:
843
+ record["last_heartbeat"] = now
844
+ _STATE["last_activity"] = now
845
+ save_state()
846
+ return 200, {"ok": True, "id": workload_id}
847
+
848
+ if path == "/workload/end":
849
+ workload_id = body.get("id")
850
+ with _LOCK:
851
+ record = (_STATE.get("workloads") or {}).get(workload_id)
852
+ if record is not None:
853
+ record["state"] = body.get("state") or (
854
+ "finished" if not body.get("returncode") else "failed"
855
+ )
856
+ record["finished_at"] = now
857
+ record["returncode"] = body.get("returncode")
858
+ _STATE["last_activity"] = now
859
+ save_state()
860
+ log_event(
861
+ "workload_finished",
862
+ workload_id=workload_id,
863
+ returncode=body.get("returncode"),
864
+ )
865
+ return 200, {"ok": True}
866
+
867
+ if path == "/lock":
868
+ ttl = body.get("ttl_seconds")
869
+ until = body.get("until")
870
+ cfg = policy_config()
871
+ if until is None and ttl:
872
+ until = now + float(ttl)
873
+ if until is None and not cfg.allow_indefinite_lock:
874
+ return 400, {
875
+ "error": "indefinite locks are disabled "
876
+ "(set lifecycle.allow_indefinite_lock: true to permit them)"
877
+ }
878
+ with _LOCK:
879
+ _STATE["locked"] = True
880
+ _STATE["locked_until"] = float(until) if until is not None else None
881
+ _STATE["last_activity"] = now
882
+ save_state()
883
+ log_event("instance_locked", locked_until=until, reason=body.get("reason", ""))
884
+ return 200, {"ok": True, "locked_until": until}
885
+
886
+ if path == "/unlock":
887
+ with _LOCK:
888
+ _STATE["locked"] = False
889
+ _STATE["locked_until"] = None
890
+ _STATE["last_activity"] = now
891
+ save_state()
892
+ log_event("instance_unlocked", reason=body.get("reason", "manual"))
893
+ return 200, {"ok": True}
894
+
895
+ if path == "/shutdown":
896
+ action = body.get("action") or ACTION_STOP
897
+ reason = body.get("reason") or "manual"
898
+ if action not in (ACTION_STOP, ACTION_DESTROY):
899
+ return 400, {"error": "action must be stop or destroy"}
900
+ with _LOCK:
901
+ # Remembered so the guard loop retries it if the provider call fails.
902
+ _STATE["pending_shutdown"] = {"action": action, "reason": reason}
903
+ save_state()
904
+ threading.Thread(
905
+ target=terminate,
906
+ args=(action, reason, {"requested_by": "cli"}),
907
+ kwargs={"force": True},
908
+ daemon=True,
909
+ ).start()
910
+ return 202, {"ok": True, "action": action}
911
+
912
+ return 404, {"error": "not found"}
913
+
914
+
915
+ # --------------------------------------------------------------------------
916
+ # entrypoint
917
+ # --------------------------------------------------------------------------
918
+
919
+
920
+ def main():
921
+ global _CONFIG, _STATE
922
+
923
+ try:
924
+ with open(CONFIG_PATH) as handle:
925
+ _CONFIG = json.load(handle)
926
+ except (OSError, ValueError) as exc:
927
+ print("[jambu-watchdog] cannot read %s: %s" % (CONFIG_PATH, exc), file=sys.stderr)
928
+ return 1
929
+
930
+ for key, value in (_CONFIG.get("env") or {}).items():
931
+ os.environ.setdefault(str(key), str(value))
932
+
933
+ _STATE = load_state()
934
+ save_state()
935
+
936
+ port = int(_CONFIG.get("port") or 8777)
937
+ server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
938
+ server.daemon_threads = True
939
+
940
+ guard = threading.Thread(target=guard_loop, daemon=True)
941
+ guard.start()
942
+
943
+ spec = probe_spec()
944
+ if spec is not None:
945
+ threading.Thread(target=probe_loop, args=(spec,), daemon=True).start()
946
+
947
+ log_event("watchdog_listening", port=port)
948
+ try:
949
+ server.serve_forever(poll_interval=1.0)
950
+ except KeyboardInterrupt: # pragma: no cover
951
+ pass
952
+ finally:
953
+ _STOPPED.set()
954
+ server.server_close()
955
+ return 0
956
+
957
+
958
+ if __name__ == "__main__":
959
+ sys.exit(main())