hugpy-worker 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.
- hugpy_worker/__init__.py +7 -0
- hugpy_worker/central_client.py +131 -0
- hugpy_worker/worker.py +600 -0
- hugpy_worker-0.1.0.dist-info/METADATA +32 -0
- hugpy_worker-0.1.0.dist-info/RECORD +8 -0
- hugpy_worker-0.1.0.dist-info/WHEEL +5 -0
- hugpy_worker-0.1.0.dist-info/entry_points.txt +2 -0
- hugpy_worker-0.1.0.dist-info/top_level.txt +1 -0
hugpy_worker/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""hugpy_worker — the per-box hugpy worker over the fitevict front door.
|
|
2
|
+
|
|
3
|
+
Installs on every box (ae, computron, a-brain, …) next to fitevict. Registers
|
|
4
|
+
with the hugpy_core router, heartbeats, and relays /infer calls to the box's
|
|
5
|
+
front door. Holds no model and makes no placement decision.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# LIFTED VERBATIM from:
|
|
2
|
+
# /srv/hugpy/src/hugpy/py/fleet/hugpy_fleet/src/hugpy_fleet/worker/agent.py
|
|
3
|
+
# lines 607-648 (WorkerRejected, central refusal markers),
|
|
4
|
+
# 678-729 (CentralClient), 7403-7417 (_load_worker_id / _save_worker_id).
|
|
5
|
+
# DROPPED: _db_heartbeat_async (Postgres heartbeat mirror; its call removed from
|
|
6
|
+
# CentralClient.heartbeat) and CentralClient.evictions_ingest — fitevict's
|
|
7
|
+
# call_log is the record and the HTTP heartbeat is the only beat.
|
|
8
|
+
"""Central node client (registration + heartbeat)."""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("hugpy_worker")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkerRejected(Exception):
|
|
21
|
+
"""Central refused this worker terminally (401 token / 403 blocked).
|
|
22
|
+
|
|
23
|
+
Distinct from a transient error or a 410 (re-register): the operator has
|
|
24
|
+
revoked/blocked us, so the agent should stop rather than retry.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self, code: int, message: str = ""):
|
|
27
|
+
super().__init__(message or f"rejected with HTTP {code}")
|
|
28
|
+
self.code = code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# CENTRAL'S OWN terminal-refusal markers. A 401/403 is only terminal (stop, don't
|
|
32
|
+
# respawn) when it is CENTRAL saying so — its register/heartbeat routes
|
|
33
|
+
# abort(401/403, description=...) with these exact strings (see
|
|
34
|
+
# hugpy_server/app/routes/worker_routes.py: "Worker is blocked by the operator."
|
|
35
|
+
# at the admission gate, "Worker enrollment token invalid or required." at the
|
|
36
|
+
# enrollment gate). A 401/403 whose body carries NEITHER marker is almost always
|
|
37
|
+
# an INTERMEDIARY between us and central — a reverse-proxy / web auth gate /
|
|
38
|
+
# captive login page (a-brain 2026-09-24: dev.hugpy.ai's web gate returned a 403
|
|
39
|
+
# HTML page while central's own record was 'approved' and central logged nothing;
|
|
40
|
+
# the agent read it as a block and os._exit(0)'d permanently). Those must be
|
|
41
|
+
# logged with the URL + body and RETRIED with backoff, never treated as a block.
|
|
42
|
+
_CENTRAL_BLOCK_MARKER = "Worker is blocked by the operator."
|
|
43
|
+
_CENTRAL_ENROLL_MARKER = "Worker enrollment token invalid or required."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_central_terminal_refusal(code: int, body: str) -> bool:
|
|
47
|
+
"""True iff an HTTP ``code``/``body`` is CENTRAL's own terminal worker refusal
|
|
48
|
+
(block or enrollment reject), as opposed to any other 401/403 from a proxy or
|
|
49
|
+
web gate sitting in front of central. Body-marker matched, case-insensitively
|
|
50
|
+
and whitespace-tolerant, so an HTML error page that wraps the description
|
|
51
|
+
still matches while a generic gate page does not."""
|
|
52
|
+
if code not in (401, 403):
|
|
53
|
+
return False
|
|
54
|
+
hay = " ".join((body or "").split()).lower()
|
|
55
|
+
if not hay:
|
|
56
|
+
# Central always sends a description; a truly empty body is NOT central's
|
|
57
|
+
# refusal (a bare proxy 403). Don't treat it as terminal.
|
|
58
|
+
return False
|
|
59
|
+
return (_CENTRAL_BLOCK_MARKER.lower() in hay
|
|
60
|
+
or _CENTRAL_ENROLL_MARKER.lower() in hay)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CentralClient:
|
|
65
|
+
def __init__(self, central_url: str, token: str | None = None):
|
|
66
|
+
# Endpoints live under /api on the central Flask app.
|
|
67
|
+
self.base = central_url.rstrip("/") + "/api/llm/workers"
|
|
68
|
+
self.token = token
|
|
69
|
+
|
|
70
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
71
|
+
data = json.dumps(payload).encode("utf-8")
|
|
72
|
+
headers = {"Content-Type": "application/json"}
|
|
73
|
+
if self.token:
|
|
74
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
self.base + path, data=data, headers=headers, method="POST",
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
80
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
81
|
+
except urllib.error.HTTPError as exc:
|
|
82
|
+
# A 401/403 is terminal (stop, don't respawn) ONLY when CENTRAL says
|
|
83
|
+
# so — its route bodies carry a specific marker (see
|
|
84
|
+
# _is_central_terminal_refusal). Any other 401/403 is an intermediary
|
|
85
|
+
# (proxy / web gate / login page) between us and central: log it with
|
|
86
|
+
# the URL + body snippet and re-raise so the caller RETRIES with
|
|
87
|
+
# backoff. Other codes (e.g. 410 "re-register") propagate unchanged.
|
|
88
|
+
if exc.code in (401, 403):
|
|
89
|
+
body = ""
|
|
90
|
+
try:
|
|
91
|
+
raw = exc.read()
|
|
92
|
+
body = raw.decode("utf-8", "replace") if raw else ""
|
|
93
|
+
except Exception: # noqa: BLE001 — body is best-effort context
|
|
94
|
+
body = ""
|
|
95
|
+
if _is_central_terminal_refusal(exc.code, body):
|
|
96
|
+
reason = exc.reason or ""
|
|
97
|
+
detail = " ".join(body.split())[:300]
|
|
98
|
+
raise WorkerRejected(
|
|
99
|
+
exc.code,
|
|
100
|
+
(reason + (": " + detail if detail else "")).strip()) from exc
|
|
101
|
+
logger.warning(
|
|
102
|
+
"central call %s returned HTTP %s but the body is NOT central's "
|
|
103
|
+
"own worker refusal — treating it as an intermediary (proxy / "
|
|
104
|
+
"web gate), NOT a block; will retry. body: %s",
|
|
105
|
+
self.base + path, exc.code,
|
|
106
|
+
(" ".join(body.split())[:300] or "(empty)"))
|
|
107
|
+
raise
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
def register(self, payload: dict) -> dict:
|
|
111
|
+
return self._post("/register", payload)
|
|
112
|
+
|
|
113
|
+
def heartbeat(self, worker_id: str, payload: dict) -> dict:
|
|
114
|
+
return self._post(f"/{worker_id}/heartbeat", payload)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_worker_id(path: str) -> str | None:
|
|
118
|
+
try:
|
|
119
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
120
|
+
return (json.load(fh) or {}).get("worker_id")
|
|
121
|
+
except (OSError, ValueError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _save_worker_id(path: str, worker_id: str) -> None:
|
|
126
|
+
try:
|
|
127
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
128
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
129
|
+
json.dump({"worker_id": worker_id}, fh)
|
|
130
|
+
except OSError:
|
|
131
|
+
logger.warning("could not persist worker id to %s", path)
|
hugpy_worker/worker.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# LIFTED from:
|
|
2
|
+
# /srv/hugpy/src/hugpy/py/fleet/hugpy_fleet/src/hugpy_fleet/worker/agent.py
|
|
3
|
+
# — the central<->worker CONTRACT only: WorkerState (6619, trimmed to the
|
|
4
|
+
# fields the contract uses), _ram_total_bytes (522), _sse (3187),
|
|
5
|
+
# _terminal_exit (15609), build_app routes /health /infer /infer/stream
|
|
6
|
+
# /infer/cancel (4606-4925), _heartbeat_loop (16047), _register (16309),
|
|
7
|
+
# _build_parser (contract flags) and main.
|
|
8
|
+
# SWAPPED: everything below the routes (execute_chat_stream / execute_prompt,
|
|
9
|
+
# _ensure_present*, gen_gate, _prepare_load_contract, spill, job_store,
|
|
10
|
+
# aggregate) -> one HTTP call to the fitevict front door
|
|
11
|
+
# (POST {front_door}/v1/chat/completions). fitevict owns evict-to-fit, load,
|
|
12
|
+
# serve and call_log; this process holds no model and decides no placement.
|
|
13
|
+
# DROPPED from the beat/register: telemetry produced by the dropped machinery
|
|
14
|
+
# (slots, allocations, pid_registry, calibration, aggregate, studio, comfy,
|
|
15
|
+
# storage, doctrine, self-update). Central's request models default them.
|
|
16
|
+
"""hugpy worker harness: central sees an ordinary worker, fitevict serves."""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
import urllib.error
|
|
28
|
+
import urllib.request
|
|
29
|
+
import uuid
|
|
30
|
+
|
|
31
|
+
from flask import Flask, Response, jsonify, request, stream_with_context
|
|
32
|
+
|
|
33
|
+
from fitevict.lifted.hardware import detect_gpus, free_ram_bytes
|
|
34
|
+
|
|
35
|
+
from . import __version__
|
|
36
|
+
from .central_client import (CentralClient, WorkerRejected, _load_worker_id,
|
|
37
|
+
_save_worker_id)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("hugpy_worker")
|
|
40
|
+
|
|
41
|
+
ENGINE = {"installed": True, "backend": "fitevict", "supports_gpu_offload": True}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WorkerState:
|
|
45
|
+
def __init__(self, name: str, url: str | None, worker_id: str | None,
|
|
46
|
+
central_url: str | None = None, port: int | None = None,
|
|
47
|
+
front_door: str = "http://127.0.0.1:8080"):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.url = url # None unless operator set --advertise/WORKER_URL
|
|
50
|
+
self.worker_id = worker_id
|
|
51
|
+
self.central_url = central_url
|
|
52
|
+
self.port = port
|
|
53
|
+
self.front_door = front_door.rstrip("/")
|
|
54
|
+
# Models central says we should serve (adopted from register/heartbeat).
|
|
55
|
+
self.assigned_models: list[str] = []
|
|
56
|
+
# In-flight request ids, so /infer/cancel can answer honestly.
|
|
57
|
+
self.inflight: set[str] = set()
|
|
58
|
+
# Last host constraints pushed to the wrapper (None = never pushed).
|
|
59
|
+
self.limits_sent: "dict | None" = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── fitevict front door (the swap) ──────────────────────────────────────────
|
|
63
|
+
def _fd_models(state: WorkerState) -> "list[dict] | None":
|
|
64
|
+
"""GET /v1/models from the front door: [{id, resident, ...discovery}], or
|
|
65
|
+
None when it could not be read (NOT an empty inventory)."""
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(state.front_door + "/v1/models", timeout=10) as r:
|
|
68
|
+
return (json.loads(r.read().decode("utf-8")) or {}).get("data") or []
|
|
69
|
+
except Exception: # noqa: BLE001
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def loaded_model_keys(state: WorkerState) -> list[str]:
|
|
74
|
+
return sorted(m["id"] for m in (_fd_models(state) or []) if m.get("resident"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _models_local(state: WorkerState) -> list[str]:
|
|
78
|
+
return sorted(m["id"] for m in (_fd_models(state) or []))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Idle-vs-serving window (legacy agent.py:1834 _SERVING_WINDOW_S).
|
|
82
|
+
_SERVING_WINDOW_S = 180.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fd_residents(state: WorkerState) -> list[dict]:
|
|
86
|
+
"""GET /residents from the front door: its running llama-server children."""
|
|
87
|
+
try:
|
|
88
|
+
with urllib.request.urlopen(state.front_door + "/residents", timeout=10) as r:
|
|
89
|
+
return (json.loads(r.read().decode("utf-8")) or {}).get("residents") or []
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _allocations(residents: list[dict]) -> list[dict]:
|
|
95
|
+
"""One kind="slot" row per front-door child, in the legacy allocation row
|
|
96
|
+
shape (agent.py _allocations, slot rows ~2495-2540): measured VRAM per pid
|
|
97
|
+
(device_source "measured"), else the declared placement ("inferred")."""
|
|
98
|
+
now = time.time()
|
|
99
|
+
rows = []
|
|
100
|
+
for c in residents:
|
|
101
|
+
if not c.get("alive"):
|
|
102
|
+
continue
|
|
103
|
+
vram = c.get("vram_bytes")
|
|
104
|
+
if vram is not None:
|
|
105
|
+
device, source = ("cuda" if vram > 0 else "cpu"), "measured"
|
|
106
|
+
else:
|
|
107
|
+
ngl = c.get("n_gpu_layers")
|
|
108
|
+
device = None if ngl is None else ("cpu" if ngl == 0 else "cuda")
|
|
109
|
+
source = "inferred" if device else None
|
|
110
|
+
last_used = c.get("last_used") or 0.0
|
|
111
|
+
row = {
|
|
112
|
+
"kind": "slot", "model_key": c["key"],
|
|
113
|
+
"slot_id": c.get("port"), "healthy": c.get("healthy"),
|
|
114
|
+
"busy": c.get("busy"), "endpoint": c.get("endpoint"),
|
|
115
|
+
"rss_bytes": c.get("ram_bytes"),
|
|
116
|
+
"n_gpu_layers": c.get("n_gpu_layers"), "ctx": c.get("ctx"),
|
|
117
|
+
"vram_bytes": vram, "device": device,
|
|
118
|
+
"last_used": last_used,
|
|
119
|
+
"serving": bool(c.get("busy")) or (last_used > 0 and now - last_used <= _SERVING_WINDOW_S),
|
|
120
|
+
"materialized": bool(c.get("healthy")),
|
|
121
|
+
}
|
|
122
|
+
if c.get("n_cpu_moe") is not None:
|
|
123
|
+
row["n_cpu_moe"] = c["n_cpu_moe"]
|
|
124
|
+
if source is not None:
|
|
125
|
+
row["device_source"] = source
|
|
126
|
+
if c.get("loading"):
|
|
127
|
+
row["loading"] = True
|
|
128
|
+
row["materialized"] = False
|
|
129
|
+
rows.append(row)
|
|
130
|
+
return rows
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _models_discovered(models: list[dict]) -> dict:
|
|
134
|
+
"""{model_key: discovery detail} — every model the box's fitevict found on
|
|
135
|
+
disk, with its directory, elected GGUF and all weight files (quant, bytes)."""
|
|
136
|
+
return {m["id"]: {k: v for k, v in m.items() if k not in ("id", "object", "owned_by")}
|
|
137
|
+
for m in models}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _fd_chat(state: WorkerState, payload: dict, request_id: str) -> "tuple[int, dict]":
|
|
141
|
+
"""Forward one central ChatRequest to the front door. Returns (status, body)."""
|
|
142
|
+
messages = payload.get("messages")
|
|
143
|
+
if not messages and payload.get("prompt"):
|
|
144
|
+
messages = [{"role": "user", "content": payload["prompt"]}]
|
|
145
|
+
body = {"model": payload.get("model_key"), "messages": messages or [],
|
|
146
|
+
"max_tokens": payload.get("max_new_tokens") or payload.get("max_tokens"),
|
|
147
|
+
"task": payload.get("task"),
|
|
148
|
+
"metadata": {"central_request_id": request_id, "worker": state.name}}
|
|
149
|
+
if isinstance(payload.get("knobs"), dict):
|
|
150
|
+
body["knobs"] = payload["knobs"] # central's pair knobs -> the wrapper's load contract
|
|
151
|
+
for k in ("temperature", "top_p", "top_k", "stop", "seed"):
|
|
152
|
+
if payload.get(k) is not None:
|
|
153
|
+
body[k] = payload[k]
|
|
154
|
+
body = {k: v for k, v in body.items() if v is not None}
|
|
155
|
+
req = urllib.request.Request(
|
|
156
|
+
state.front_door + "/v1/chat/completions",
|
|
157
|
+
data=json.dumps(body).encode("utf-8"),
|
|
158
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(req, timeout=900) as r:
|
|
161
|
+
return r.status, json.loads(r.read().decode("utf-8") or "{}")
|
|
162
|
+
except urllib.error.HTTPError as exc:
|
|
163
|
+
try:
|
|
164
|
+
return exc.code, json.loads(exc.read().decode("utf-8") or "{}")
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
return exc.code, {"error": {"message": str(exc)}}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _reply_parts(body: dict) -> "tuple[str, str, dict, dict]":
|
|
170
|
+
"""(text, finish_reason, usage, timings) out of an OpenAI chat payload."""
|
|
171
|
+
ch = ((body.get("choices") or [{}])[0]) if isinstance(body, dict) else {}
|
|
172
|
+
text = ((ch.get("message") or {}).get("content")) or ""
|
|
173
|
+
return (text, ch.get("finish_reason") or "stop",
|
|
174
|
+
body.get("usage") or {}, body.get("timings") or {})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _error_text(body: dict) -> str:
|
|
178
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
179
|
+
if isinstance(err, dict):
|
|
180
|
+
return str(err.get("message") or err)
|
|
181
|
+
return str(err or "front door error")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── lifted helpers ──────────────────────────────────────────────────────────
|
|
185
|
+
def _ram_total_bytes() -> int | None:
|
|
186
|
+
"""RAW physical RAM (MemTotal) in bytes — the pool-budget denominator.
|
|
187
|
+
|
|
188
|
+
Unlike _free_ram_bytes (reserve-adjusted + RAM_MAX-capped so central plans
|
|
189
|
+
against budgetable RAM), this is the box's total installed memory, so the
|
|
190
|
+
console can render used-vs-total. Best-effort, mirroring
|
|
191
|
+
_platform/hardware.free_ram_bytes: psutil first, then /proc/meminfo, else
|
|
192
|
+
None (never fabricated)."""
|
|
193
|
+
try:
|
|
194
|
+
import psutil
|
|
195
|
+
return int(psutil.virtual_memory().total)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
if sys.platform.startswith("linux"):
|
|
199
|
+
try:
|
|
200
|
+
with open("/proc/meminfo", "r", encoding="utf-8") as fh:
|
|
201
|
+
for line in fh:
|
|
202
|
+
if line.startswith("MemTotal:"):
|
|
203
|
+
return int(line.split()[1]) * 1024
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _sse(payload: dict) -> bytes:
|
|
210
|
+
# werkzeug's WSGI server asserts the app yields bytes, not str — so encode
|
|
211
|
+
# here. (gunicorn is more lenient, but the worker runs the dev server.)
|
|
212
|
+
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _terminal_exit(exc: "WorkerRejected") -> None:
|
|
216
|
+
"""Stop the agent for good after central refused it (401/403).
|
|
217
|
+
|
|
218
|
+
Called from the daemon heartbeat thread too, so it must kill the whole
|
|
219
|
+
process (the main thread is blocked in the inference server) — hence
|
|
220
|
+
os._exit. Exit code 0 so a ``Restart=on-failure`` unit does NOT respawn a
|
|
221
|
+
deliberately-evicted worker (transient crashes still exit non-zero/killed and
|
|
222
|
+
are restarted as before).
|
|
223
|
+
"""
|
|
224
|
+
if getattr(exc, "code", None) == 403:
|
|
225
|
+
logger.error("central BLOCKED this worker (403): %s. Stopping — have the "
|
|
226
|
+
"operator Admit it in the console to rejoin.", exc)
|
|
227
|
+
else:
|
|
228
|
+
logger.error("central refused enrollment (401): %s. Stopping — re-enroll "
|
|
229
|
+
"with a valid WORKER_ENROLL_TOKEN.", exc)
|
|
230
|
+
os._exit(0)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sync_assignment(state: WorkerState, worker: dict | None) -> None:
|
|
234
|
+
models = (worker or {}).get("models")
|
|
235
|
+
if isinstance(models, list):
|
|
236
|
+
state.assigned_models = [str(m) for m in models]
|
|
237
|
+
_apply_limits(state, (worker or {}).get("fitevict_limits"))
|
|
238
|
+
_self_update_if_needed(state, (worker or {}).get("required_packages"))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── self-update (shape lifted from legacy agent.py _self_update_if_needed):
|
|
242
|
+
# central names the package versions to run (the latest on PyPI); a box behind
|
|
243
|
+
# them pip-installs exactly those from PyPI, then re-execs. Never destructive:
|
|
244
|
+
# it waits until nothing is in flight or loading, and restarts the front door
|
|
245
|
+
# (which drops its resident models) only when hugpy-wrapper itself changed.
|
|
246
|
+
_UPDATE_RETRY_BACKOFF = 600.0
|
|
247
|
+
UPDATE_STATE_FILE = os.path.expanduser(os.environ.get("HUGPY_WORKER_UPDATE_STATE",
|
|
248
|
+
"~/.hugpy_worker/update.json"))
|
|
249
|
+
WRAPPER_PKG = "hugpy-wrapper"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _editable_install() -> bool:
|
|
253
|
+
"""A dev box runs the source tree (pip -e): it never self-updates."""
|
|
254
|
+
try:
|
|
255
|
+
from importlib.metadata import distribution
|
|
256
|
+
raw = distribution("hugpy_worker").read_text("direct_url.json") or "{}"
|
|
257
|
+
return bool((json.loads(raw).get("dir_info") or {}).get("editable"))
|
|
258
|
+
except Exception: # noqa: BLE001
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _installed(pkg: str) -> "str | None":
|
|
263
|
+
try:
|
|
264
|
+
from importlib.metadata import version
|
|
265
|
+
return version(pkg)
|
|
266
|
+
except Exception: # noqa: BLE001
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _load_update_state() -> dict:
|
|
271
|
+
try:
|
|
272
|
+
with open(UPDATE_STATE_FILE, "r", encoding="utf-8") as fh:
|
|
273
|
+
return json.load(fh) or {}
|
|
274
|
+
except (OSError, ValueError):
|
|
275
|
+
return {}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _save_update_state(d: dict) -> None:
|
|
279
|
+
try:
|
|
280
|
+
os.makedirs(os.path.dirname(UPDATE_STATE_FILE), exist_ok=True)
|
|
281
|
+
with open(UPDATE_STATE_FILE, "w", encoding="utf-8") as fh:
|
|
282
|
+
json.dump(d, fh)
|
|
283
|
+
except OSError:
|
|
284
|
+
logger.warning("could not write %s", UPDATE_STATE_FILE)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _box_idle(state: WorkerState) -> bool:
|
|
288
|
+
"""Nothing in flight here and nothing busy or loading in the front door."""
|
|
289
|
+
if state.inflight:
|
|
290
|
+
return False
|
|
291
|
+
try:
|
|
292
|
+
with urllib.request.urlopen(state.front_door + "/residents", timeout=5) as r:
|
|
293
|
+
res = (json.loads(r.read().decode("utf-8")) or {}).get("residents") or []
|
|
294
|
+
except Exception: # noqa: BLE001 — front door down: nothing of its to lose
|
|
295
|
+
return True
|
|
296
|
+
return not any(x.get("busy") or x.get("loading") for x in res)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _self_update_if_needed(state: WorkerState, required: "dict | None") -> None:
|
|
300
|
+
if not isinstance(required, dict) or not required or _editable_install():
|
|
301
|
+
return # central isn't naming versions, or this is a dev (source) install
|
|
302
|
+
want = {p: v for p, v in required.items() if v and _installed(p) != v}
|
|
303
|
+
if not want:
|
|
304
|
+
return
|
|
305
|
+
st = _load_update_state()
|
|
306
|
+
if st.get("target") == want and (time.time() - st.get("at", 0)) < _UPDATE_RETRY_BACKOFF:
|
|
307
|
+
return # already tried this exact target recently; back off
|
|
308
|
+
if not _box_idle(state):
|
|
309
|
+
return # retried on the next beat, once the box is idle
|
|
310
|
+
import subprocess
|
|
311
|
+
logger.info("self-update from PyPI: %s", ", ".join(
|
|
312
|
+
f"{p} {_installed(p) or '(none)'} -> {v}" for p, v in want.items()))
|
|
313
|
+
rc = 1
|
|
314
|
+
try:
|
|
315
|
+
# the wrapper's pre-rename dist owns the same files: remove it first
|
|
316
|
+
if _installed("fitevict") is not None:
|
|
317
|
+
subprocess.call([sys.executable, "-m", "pip", "uninstall", "-y", "-q", "fitevict"])
|
|
318
|
+
rc = subprocess.call([sys.executable, "-m", "pip", "install", "-q",
|
|
319
|
+
*[f"{p}=={v}" for p, v in want.items()]])
|
|
320
|
+
except Exception as exc: # noqa: BLE001
|
|
321
|
+
logger.warning("self-update install failed: %s", exc)
|
|
322
|
+
if rc != 0:
|
|
323
|
+
_save_update_state({"target": want, "at": time.time(), "rc": rc})
|
|
324
|
+
logger.warning("self-update failed (rc=%s); staying on the installed versions", rc)
|
|
325
|
+
return
|
|
326
|
+
_save_update_state({"installed": want, "at": time.time(), "rc": 0})
|
|
327
|
+
if WRAPPER_PKG in want:
|
|
328
|
+
logger.info("self-update: %s changed; restarting the front door", WRAPPER_PKG)
|
|
329
|
+
try:
|
|
330
|
+
subprocess.call(["systemctl", "--user", "restart", "fitevict-frontdoor.service"],
|
|
331
|
+
timeout=60)
|
|
332
|
+
except Exception as exc: # noqa: BLE001
|
|
333
|
+
logger.warning("front door restart failed: %s", exc)
|
|
334
|
+
logger.info("self-update installed %s; re-exec", want)
|
|
335
|
+
os.execv(sys.executable, [sys.executable, "-m", "hugpy_worker.worker", *sys.argv[1:]])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _apply_limits(state: WorkerState, lim) -> None:
|
|
339
|
+
"""Hand central's host constraints (cap + protected set) to the wrapper,
|
|
340
|
+
only when they changed. Constraints, not knobs: the wrapper holds them."""
|
|
341
|
+
if not isinstance(lim, dict):
|
|
342
|
+
return
|
|
343
|
+
want = {"gpu_limit": lim.get("gpu_limit"), "ram_limit": lim.get("ram_limit"),
|
|
344
|
+
"protected": sorted(lim.get("protected") or [])}
|
|
345
|
+
if want == state.limits_sent:
|
|
346
|
+
return
|
|
347
|
+
req = urllib.request.Request(state.front_door + "/limits",
|
|
348
|
+
data=json.dumps(want).encode("utf-8"),
|
|
349
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
350
|
+
try:
|
|
351
|
+
with urllib.request.urlopen(req, timeout=10):
|
|
352
|
+
state.limits_sent = want
|
|
353
|
+
logger.info("wrapper limits set: %s", want)
|
|
354
|
+
except Exception as exc: # noqa: BLE001 — retried on the next beat
|
|
355
|
+
logger.warning("could not set wrapper limits: %s", exc)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# HTTP server
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
def build_app(state: WorkerState) -> Flask:
|
|
362
|
+
app = Flask("hugpy_worker")
|
|
363
|
+
|
|
364
|
+
@app.route("/health", methods=["GET"])
|
|
365
|
+
def health():
|
|
366
|
+
return jsonify(
|
|
367
|
+
{
|
|
368
|
+
"ok": True,
|
|
369
|
+
"worker_id": state.worker_id,
|
|
370
|
+
"name": state.name,
|
|
371
|
+
"gpus": detect_gpus(),
|
|
372
|
+
"engine": ENGINE,
|
|
373
|
+
"front_door": state.front_door,
|
|
374
|
+
"assigned_models": state.assigned_models,
|
|
375
|
+
"loaded_models": loaded_model_keys(state),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
@app.route("/infer", methods=["POST"])
|
|
380
|
+
def infer():
|
|
381
|
+
payload = request.get_json(silent=True) or {}
|
|
382
|
+
req_id = str(payload.pop("request_id", "") or uuid.uuid4().hex)
|
|
383
|
+
payload.pop("spill", None)
|
|
384
|
+
if not payload.get("model_key"):
|
|
385
|
+
return jsonify({
|
|
386
|
+
"ok": False, "error": "model_key is required",
|
|
387
|
+
"worker": {"id": state.worker_id, "name": state.name},
|
|
388
|
+
}), 400
|
|
389
|
+
state.inflight.add(req_id)
|
|
390
|
+
try:
|
|
391
|
+
status, body = _fd_chat(state, payload, req_id)
|
|
392
|
+
finally:
|
|
393
|
+
state.inflight.discard(req_id)
|
|
394
|
+
if status != 200:
|
|
395
|
+
return jsonify({
|
|
396
|
+
"ok": False, "error": _error_text(body),
|
|
397
|
+
"worker": {"id": state.worker_id, "name": state.name},
|
|
398
|
+
}), status
|
|
399
|
+
text, finish, usage, timings = _reply_parts(body)
|
|
400
|
+
return jsonify({"ok": True, "text": text, "finish_reason": finish,
|
|
401
|
+
"usage": usage, "timings": timings,
|
|
402
|
+
"request_id": req_id})
|
|
403
|
+
|
|
404
|
+
@app.route("/infer/stream", methods=["POST"])
|
|
405
|
+
def infer_stream():
|
|
406
|
+
payload = request.get_json(silent=True) or {}
|
|
407
|
+
payload.pop("spill", None)
|
|
408
|
+
# Caller-supplied id for cancellation; else generate one. Echo it back
|
|
409
|
+
# as the first SSE event so the client can cancel this exact request.
|
|
410
|
+
req_id = str(payload.pop("request_id", "") or uuid.uuid4().hex)
|
|
411
|
+
if not payload.get("model_key"):
|
|
412
|
+
return jsonify({
|
|
413
|
+
"ok": False, "error": "model_key is required",
|
|
414
|
+
"worker": {"id": state.worker_id, "name": state.name},
|
|
415
|
+
}), 400
|
|
416
|
+
|
|
417
|
+
def _generate():
|
|
418
|
+
state.inflight.add(req_id)
|
|
419
|
+
try:
|
|
420
|
+
yield _sse({"type": "request", "request_id": req_id})
|
|
421
|
+
# The front door answers whole (non-streaming): the reply rides
|
|
422
|
+
# as one token event, then done with the engine's usage/timings.
|
|
423
|
+
status, body = _fd_chat(state, payload, req_id)
|
|
424
|
+
if status != 200:
|
|
425
|
+
yield _sse({"type": "error", "message": _error_text(body),
|
|
426
|
+
"code": str(status)})
|
|
427
|
+
return
|
|
428
|
+
text, finish, usage, timings = _reply_parts(body)
|
|
429
|
+
if text:
|
|
430
|
+
yield _sse({"type": "token", "text": text})
|
|
431
|
+
done = {"type": "done", "finish_reason": finish}
|
|
432
|
+
if usage:
|
|
433
|
+
done["usage"] = usage
|
|
434
|
+
if timings:
|
|
435
|
+
done["timings"] = timings
|
|
436
|
+
yield _sse(done)
|
|
437
|
+
finally:
|
|
438
|
+
state.inflight.discard(req_id)
|
|
439
|
+
|
|
440
|
+
return Response(
|
|
441
|
+
stream_with_context(_generate()),
|
|
442
|
+
mimetype="text/event-stream",
|
|
443
|
+
headers={
|
|
444
|
+
"Cache-Control": "no-cache",
|
|
445
|
+
"X-Accel-Buffering": "no",
|
|
446
|
+
"Connection": "keep-alive",
|
|
447
|
+
},
|
|
448
|
+
direct_passthrough=True,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
@app.route("/infer/cancel/<request_id>", methods=["POST"])
|
|
452
|
+
def infer_cancel(request_id):
|
|
453
|
+
# Wire contract: 404 for unknown/finished requests. An in-flight request
|
|
454
|
+
# cannot be stopped yet — the front door has no cancel — so say so.
|
|
455
|
+
if request_id not in state.inflight:
|
|
456
|
+
return jsonify({"cancelled": False, "reason": "unknown or finished request"}), 404
|
|
457
|
+
return jsonify({"cancelled": False, "request_id": request_id,
|
|
458
|
+
"reason": "fitevict front door has no cancel yet"}), 409
|
|
459
|
+
|
|
460
|
+
return app
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Registration + heartbeat
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
def _beat_fields(state: WorkerState) -> dict:
|
|
467
|
+
return {
|
|
468
|
+
"gpus": detect_gpus(),
|
|
469
|
+
"url": state.url, # None -> central uses the source IP
|
|
470
|
+
"port": state.port,
|
|
471
|
+
"pkg_version": __version__,
|
|
472
|
+
"free_ram": free_ram_bytes(),
|
|
473
|
+
"ram_total": _ram_total_bytes(),
|
|
474
|
+
"engine": ENGINE,
|
|
475
|
+
"pool": os.environ.get("WORKER_POOL", ""),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _fd_storage(state: WorkerState) -> dict | None:
|
|
480
|
+
"""GET /storage from the front door: the model drive survey."""
|
|
481
|
+
try:
|
|
482
|
+
with urllib.request.urlopen(state.front_door + "/storage", timeout=20) as r:
|
|
483
|
+
return json.loads(r.read().decode("utf-8")) or None
|
|
484
|
+
except Exception: # noqa: BLE001
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _model_fields(state: WorkerState) -> dict:
|
|
489
|
+
"""What this box holds: discovered models (with hugpy.json + weights),
|
|
490
|
+
which are loaded, and the running children as allocation rows."""
|
|
491
|
+
models = _fd_models(state)
|
|
492
|
+
if models is None:
|
|
493
|
+
# Inventory unreadable this beat (front door down/restarting): report
|
|
494
|
+
# NO inventory rather than an empty one — an empty object is
|
|
495
|
+
# authoritative ("the scan found nothing") and would erase this box's
|
|
496
|
+
# models upstream (instructions/hugpy/model_discovery, rule 2).
|
|
497
|
+
return {"allocations": _allocations(_fd_residents(state)),
|
|
498
|
+
"storage": _fd_storage(state)}
|
|
499
|
+
return {
|
|
500
|
+
"loaded_models": sorted(m["id"] for m in models if m.get("resident")),
|
|
501
|
+
"models_local": sorted(m["id"] for m in models),
|
|
502
|
+
"models_discovered": _models_discovered(models),
|
|
503
|
+
"allocations": _allocations(_fd_residents(state)),
|
|
504
|
+
"storage": _fd_storage(state),
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _register(client: CentralClient, state: WorkerState, args) -> None:
|
|
509
|
+
models = [m.strip() for m in (args.models or "").split(",") if m.strip()]
|
|
510
|
+
payload = {
|
|
511
|
+
"name": state.name,
|
|
512
|
+
"models": models or None,
|
|
513
|
+
"worker_id": state.worker_id,
|
|
514
|
+
**_beat_fields(state),
|
|
515
|
+
# Sent on register too, so a router restart (410 -> re-register) never
|
|
516
|
+
# leaves the box listed with no models until the next beat.
|
|
517
|
+
**_model_fields(state),
|
|
518
|
+
}
|
|
519
|
+
try:
|
|
520
|
+
worker = client.register(payload)
|
|
521
|
+
except WorkerRejected as exc:
|
|
522
|
+
_terminal_exit(exc) # does not return — blocked/revoked, don't retry
|
|
523
|
+
state.worker_id = worker.get("id", state.worker_id)
|
|
524
|
+
if state.worker_id:
|
|
525
|
+
_save_worker_id(args.id_file, state.worker_id)
|
|
526
|
+
_sync_assignment(state, worker)
|
|
527
|
+
logger.info("registered as worker id=%s serving models=%s", state.worker_id, worker.get("models"))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _heartbeat_loop(client: CentralClient, state: WorkerState, args) -> None:
|
|
531
|
+
while True:
|
|
532
|
+
time.sleep(args.heartbeat)
|
|
533
|
+
try:
|
|
534
|
+
worker = client.heartbeat(
|
|
535
|
+
state.worker_id,
|
|
536
|
+
{**_beat_fields(state), **_model_fields(state)},
|
|
537
|
+
)
|
|
538
|
+
# Adopt any assignment change made in the UI.
|
|
539
|
+
_sync_assignment(state, worker)
|
|
540
|
+
except WorkerRejected as exc:
|
|
541
|
+
_terminal_exit(exc) # does not return
|
|
542
|
+
except urllib.error.HTTPError as exc:
|
|
543
|
+
if exc.code == 410:
|
|
544
|
+
# Central forgot us (restart / cleared registry) — re-register.
|
|
545
|
+
logger.warning("central returned 410; re-registering")
|
|
546
|
+
_register(client, state, args)
|
|
547
|
+
else:
|
|
548
|
+
logger.warning("heartbeat HTTP %s", exc.code)
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
logger.warning("heartbeat failed: %s", exc)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
554
|
+
p = argparse.ArgumentParser(prog="hugpy_worker")
|
|
555
|
+
p.add_argument("--central", default=os.environ.get("HUGPY_BASE_URL")
|
|
556
|
+
or os.environ.get("WORKER_CENTRAL_URL"),
|
|
557
|
+
help="Central base URL. Omitted -> serve locally, never register.")
|
|
558
|
+
p.add_argument("--token", default=os.environ.get("WORKER_ENROLL_TOKEN"),
|
|
559
|
+
help="Enrollment token issued by the console (hpw_...). Sent as a "
|
|
560
|
+
"Bearer credential on register/heartbeat.")
|
|
561
|
+
p.add_argument("--name", default=os.environ.get("WORKER_NAME", socket.gethostname()))
|
|
562
|
+
p.add_argument("--host", default=os.environ.get("WORKER_HOST", "0.0.0.0"),
|
|
563
|
+
help="Bind address for the worker's inference server")
|
|
564
|
+
p.add_argument("--port", type=int, default=int(os.environ.get("WORKER_PORT", "9100")))
|
|
565
|
+
p.add_argument("--advertise", default=os.environ.get("WORKER_URL"),
|
|
566
|
+
help="URL the central node should call back on "
|
|
567
|
+
"(defaults to http://<host>:<port>)")
|
|
568
|
+
p.add_argument("--models", default=os.environ.get("WORKER_MODELS", ""),
|
|
569
|
+
help="Comma-separated model_keys to self-assign on registration")
|
|
570
|
+
p.add_argument("--heartbeat", type=float, default=float(os.environ.get("WORKER_HEARTBEAT", "15")))
|
|
571
|
+
p.add_argument("--id-file", default=os.environ.get(
|
|
572
|
+
"HUGPY_WORKER_ID_FILE", os.path.expanduser("~/.hugpy_worker/worker_id.json")))
|
|
573
|
+
p.add_argument("--front-door", default=os.environ.get(
|
|
574
|
+
"HUGPY_WORKER_FRONT_DOOR", "http://127.0.0.1:8080"),
|
|
575
|
+
help="fitevict front door base URL")
|
|
576
|
+
return p
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def main(argv: list[str] | None = None) -> int:
|
|
580
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
581
|
+
args = _build_parser().parse_args(argv)
|
|
582
|
+
state = WorkerState(name=args.name, url=args.advertise,
|
|
583
|
+
worker_id=_load_worker_id(args.id_file),
|
|
584
|
+
central_url=args.central, port=args.port,
|
|
585
|
+
front_door=args.front_door)
|
|
586
|
+
if args.central:
|
|
587
|
+
client = CentralClient(args.central, token=args.token)
|
|
588
|
+
_register(client, state, args)
|
|
589
|
+
threading.Thread(target=_heartbeat_loop, args=(client, state, args),
|
|
590
|
+
name="hugpy-worker-heartbeat", daemon=True).start()
|
|
591
|
+
else:
|
|
592
|
+
logger.info("no --central: serving locally only, not registered")
|
|
593
|
+
logger.info("hugpy_worker %s on %s:%s -> front door %s",
|
|
594
|
+
state.name, args.host, args.port, state.front_door)
|
|
595
|
+
build_app(state).run(host=args.host, port=args.port, threaded=True)
|
|
596
|
+
return 0
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
sys.exit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hugpy-worker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-box hugpy worker over the fitevict front door: register, heartbeat, /infer, /infer/stream, cancel, health — lifted from hugpy_fleet.worker; all loading and serving delegated to fitevict.
|
|
5
|
+
Author-email: putkoff <support@hugpy.ai>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://hugpy.ai
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: hugpy-wrapper
|
|
17
|
+
Requires-Dist: flask>=2
|
|
18
|
+
|
|
19
|
+
# hugpy-worker
|
|
20
|
+
|
|
21
|
+
pip install hugpy-worker # pulls hugpy-wrapper (the front door)
|
|
22
|
+
|
|
23
|
+
Per-box hugpy worker over the fitevict front door. Central sees an ordinary hugpy
|
|
24
|
+
worker (register, heartbeat, `/infer`, `/infer/stream`, `/infer/cancel`, `/health`);
|
|
25
|
+
every inference is forwarded to fitevict's `/v1/chat/completions`, which owns
|
|
26
|
+
evict-to-fit, load, serve and call_log. The contract is lifted from
|
|
27
|
+
`hugpy_fleet.worker.agent`; nothing below it is.
|
|
28
|
+
|
|
29
|
+
hugpy-worker --central http://127.0.0.1:7020 --name ae-worker --port 9200 \
|
|
30
|
+
--advertise http://192.168.1.100:9200 --front-door http://127.0.0.1:8080
|
|
31
|
+
|
|
32
|
+
Without `--central` it serves locally and never registers.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hugpy_worker/__init__.py,sha256=VMQXUKOO2uu63VdnE_QCBYhDXUpJGRsj66ksjhgjHIU,319
|
|
2
|
+
hugpy_worker/central_client.py,sha256=Hkw07v2h5dgim7Gz0GDUXC2BmnT1FPEp4FdnLEiebWM,6027
|
|
3
|
+
hugpy_worker/worker.py,sha256=mPRSRpadclp4C1yYS5Nj5ZA1IifyYy3N6UgKXBUMM1U,26682
|
|
4
|
+
hugpy_worker-0.1.0.dist-info/METADATA,sha256=SAWVzbEUxRuewGNTCeCAFYf1tbNKf8-99sa1O8l-uJU,1458
|
|
5
|
+
hugpy_worker-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
hugpy_worker-0.1.0.dist-info/entry_points.txt,sha256=mwSMQpIIxbHQvpbT0UBqQOlMa-g6wxOdrr6iYNoTWFc,58
|
|
7
|
+
hugpy_worker-0.1.0.dist-info/top_level.txt,sha256=prqGr888Rup_0WQiNXltc-D5wIn31-3k-gPORCsA5ug,13
|
|
8
|
+
hugpy_worker-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hugpy_worker
|