hugpy-fleet 0.2.0a0__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 (89) hide show
  1. hugpy_fleet/__init__.py +53 -0
  2. hugpy_fleet/central/__init__.py +0 -0
  3. hugpy_fleet/central/admission_gate.py +78 -0
  4. hugpy_fleet/central/agent_nodes.py +401 -0
  5. hugpy_fleet/central/archive_gate.py +125 -0
  6. hugpy_fleet/central/blocklist.py +267 -0
  7. hugpy_fleet/central/calibration.py +390 -0
  8. hugpy_fleet/central/config.py +101 -0
  9. hugpy_fleet/central/enrollment_tokens.py +220 -0
  10. hugpy_fleet/central/evict_policy.py +109 -0
  11. hugpy_fleet/central/evictions.py +1007 -0
  12. hugpy_fleet/central/feeds.py +240 -0
  13. hugpy_fleet/central/heartbeat_db.py +201 -0
  14. hugpy_fleet/central/model_groups.py +389 -0
  15. hugpy_fleet/central/model_metrics.py +901 -0
  16. hugpy_fleet/central/oracle_adapters.py +72 -0
  17. hugpy_fleet/central/peers.py +124 -0
  18. hugpy_fleet/central/phone_brick_store.py +330 -0
  19. hugpy_fleet/central/pid_attribution.py +249 -0
  20. hugpy_fleet/central/placement.py +509 -0
  21. hugpy_fleet/central/pool_guard.py +167 -0
  22. hugpy_fleet/central/priority_group_settings.py +269 -0
  23. hugpy_fleet/central/priority_groups.py +482 -0
  24. hugpy_fleet/central/task_templates.py +323 -0
  25. hugpy_fleet/central/worker_http.py +434 -0
  26. hugpy_fleet/central/workers.py +6178 -0
  27. hugpy_fleet/doctrine/__init__.py +69 -0
  28. hugpy_fleet/doctrine/doctor.py +475 -0
  29. hugpy_fleet/doctrine/doctrine.py +624 -0
  30. hugpy_fleet/doctrine/fleet_runbook.json +95 -0
  31. hugpy_fleet/doctrine/runbook.py +33 -0
  32. hugpy_fleet/fleet_manager/__init__.py +46 -0
  33. hugpy_fleet/fleet_manager/templates.py +565 -0
  34. hugpy_fleet/gguf_worker/__init__.py +10 -0
  35. hugpy_fleet/gguf_worker/__main__.py +4 -0
  36. hugpy_fleet/gguf_worker/agent.py +852 -0
  37. hugpy_fleet/phone_brick/__init__.py +64 -0
  38. hugpy_fleet/phone_brick/__main__.py +135 -0
  39. hugpy_fleet/phone_brick/analyze.py +136 -0
  40. hugpy_fleet/phone_brick/bootstrap.sh +141 -0
  41. hugpy_fleet/phone_brick/client.py +75 -0
  42. hugpy_fleet/phone_brick/consensus.py +35 -0
  43. hugpy_fleet/phone_brick/detector.py +227 -0
  44. hugpy_fleet/phone_brick/http.py +17 -0
  45. hugpy_fleet/phone_brick/orchestrator.py +148 -0
  46. hugpy_fleet/phone_brick/protocol.py +96 -0
  47. hugpy_fleet/phone_brick/registration.py +134 -0
  48. hugpy_fleet/phone_brick/rendering.py +43 -0
  49. hugpy_fleet/phone_brick/rpc_backend.py +359 -0
  50. hugpy_fleet/phone_brick/schemas.py +196 -0
  51. hugpy_fleet/phone_brick/worker.py +244 -0
  52. hugpy_fleet/phone_brick_orchestrator/__init__.py +11 -0
  53. hugpy_fleet/phone_brick_orchestrator/runner.py +141 -0
  54. hugpy_fleet/py.typed +0 -0
  55. hugpy_fleet/toks_report.py +87 -0
  56. hugpy_fleet/wire.py +173 -0
  57. hugpy_fleet/worker/__init__.py +7 -0
  58. hugpy_fleet/worker/__main__.py +4 -0
  59. hugpy_fleet/worker/_studio_subproc.py +286 -0
  60. hugpy_fleet/worker/agent.py +12733 -0
  61. hugpy_fleet/worker/aggregate.py +596 -0
  62. hugpy_fleet/worker/aptitude/__init__.py +15 -0
  63. hugpy_fleet/worker/aptitude/cases.py +364 -0
  64. hugpy_fleet/worker/aptitude/parse.py +73 -0
  65. hugpy_fleet/worker/aptitude/score.py +401 -0
  66. hugpy_fleet/worker/aptitude/selftest.py +227 -0
  67. hugpy_fleet/worker/bootstrap.sh +214 -0
  68. hugpy_fleet/worker/budget.py +835 -0
  69. hugpy_fleet/worker/comfy_ledger.py +221 -0
  70. hugpy_fleet/worker/comfy_watchdog.py +407 -0
  71. hugpy_fleet/worker/deploy/install.sh +54 -0
  72. hugpy_fleet/worker/environment_report.py +500 -0
  73. hugpy_fleet/worker/flex.py +689 -0
  74. hugpy_fleet/worker/gen_gate.py +323 -0
  75. hugpy_fleet/worker/imports.py +74 -0
  76. hugpy_fleet/worker/install.py +397 -0
  77. hugpy_fleet/worker/logs.py +233 -0
  78. hugpy_fleet/worker/pid_registry.py +601 -0
  79. hugpy_fleet/worker/plugins.py +196 -0
  80. hugpy_fleet/worker/slot_child.py +26 -0
  81. hugpy_fleet/worker/storage_hooks.py +68 -0
  82. hugpy_fleet/worker/studio_render.py +357 -0
  83. hugpy_fleet/worker/studio_reserve.py +161 -0
  84. hugpy_fleet-0.2.0a0.dist-info/METADATA +43 -0
  85. hugpy_fleet-0.2.0a0.dist-info/RECORD +89 -0
  86. hugpy_fleet-0.2.0a0.dist-info/WHEEL +5 -0
  87. hugpy_fleet-0.2.0a0.dist-info/entry_points.txt +4 -0
  88. hugpy_fleet-0.2.0a0.dist-info/licenses/LICENSE +41 -0
  89. hugpy_fleet-0.2.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,53 @@
1
+ """hugpy-fleet: central fleet state plus the full, GGUF and phone workers.
2
+
3
+ Public surface (kept light — no engine/HTTP imports at package import):
4
+
5
+ * ``hugpy_fleet.wire`` — the DTOs central and workers exchange (re-exported).
6
+ * ``hugpy_fleet.central.placement`` — ``install()``/``uninstall()`` wiring the
7
+ fleet into ``hugpy_engine.placement`` (the server calls ``install()``).
8
+ * ``hugpy_fleet.central.config`` — state roots (``settings``, ``configure``).
9
+ * Entry points: ``hugpy-worker``, ``hugpy-gguf-worker``, ``hugpy-phone-brick``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ try: # the installed distribution's version: the workspace tag/commit, never a literal
15
+ from importlib.metadata import version as _dist_version
16
+ __version__ = _dist_version("hugpy-fleet")
17
+ except Exception: # noqa: BLE001 — source tree without metadata
18
+ __version__ = "0.0.0+unknown"
19
+
20
+ from hugpy_fleet.wire import ( # noqa: E402 — stdlib-only DTOs
21
+ OPERATION_VERBS,
22
+ EnrollmentRequest,
23
+ ModelAssignment,
24
+ WorkerHeartbeat,
25
+ WorkerOperation,
26
+ WorkerRegistration,
27
+ WireDTO,
28
+ )
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "OPERATION_VERBS",
33
+ "EnrollmentRequest",
34
+ "ModelAssignment",
35
+ "WireDTO",
36
+ "WorkerHeartbeat",
37
+ "WorkerOperation",
38
+ "WorkerRegistration",
39
+ "install",
40
+ "uninstall",
41
+ "configure",
42
+ ]
43
+
44
+
45
+ def __getattr__(name: str):
46
+ # Lazy: keeps ``import hugpy_fleet`` free of engine imports.
47
+ if name in ("install", "uninstall"):
48
+ from hugpy_fleet.central import placement
49
+ return getattr(placement, name)
50
+ if name == "configure":
51
+ from hugpy_fleet.central.config import configure
52
+ return configure
53
+ raise AttributeError(f"module 'hugpy_fleet' has no attribute {name!r}")
File without changes
@@ -0,0 +1,78 @@
1
+ """Central's ADMISSION gate — refuse to route to a model the post-download
2
+ admission held (hugpy_storage.admission / hugpy_ops.admission).
3
+
4
+ The verdict lives on the model's own ``hugpy.json["admission"]``; this module
5
+ reads it through the PERSISTED marker aspect every catalog surface already
6
+ reads (``lookup_physical(.., ASPECT_MARKER)``, derived live on a miss), so a
7
+ routing decision costs a dict lookup, not a file read per request.
8
+
9
+ ``pending`` routes (the benchmark that decides admission has to reach the
10
+ model); only ``held`` refuses. The refusal text carries :data:`HELD_MARKER`,
11
+ which the engine's cold-hold classifier treats as PERMANENT (fail fast, never
12
+ retried). An explicit ``alloc.force=true`` on a request bypasses the gate for
13
+ diagnosis — that decision is the engine resolver's (resolvers/remote.py), which
14
+ is the only place that sees the request.
15
+
16
+ Fail-open like the blocklist: a gate that cannot read the marker answers
17
+ "not held" — a routing gate that raises is worse than a momentarily-unheld
18
+ model.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ HELD_MARKER = "held from the serving pool by admission"
28
+
29
+
30
+ def _row_and_key(model_key: str):
31
+ from hugpy_fleet.central import workers as _w
32
+ key = _w._canonical_registry_key(model_key)
33
+ return key, _w._registry_row(key)
34
+
35
+
36
+ def admission_block(model_key: Optional[str]) -> Optional[dict]:
37
+ """The model's admission block (``{"status", "reason", ...}``) or None."""
38
+ if not model_key:
39
+ return None
40
+ try:
41
+ key, row = _row_and_key(str(model_key))
42
+ if row is None:
43
+ return None
44
+ from hugpy_storage.model_physical import ASPECT_MARKER, lookup_physical
45
+ fields, state = lookup_physical(key, row, ASPECT_MARKER)
46
+ if state != "fresh":
47
+ from hugpy_storage.console.model_physical import marker_fields
48
+ fields = marker_fields(row, key)
49
+ marker = (fields or {}).get("hugpy_marker") or {}
50
+ block = marker.get("admission") if isinstance(marker, dict) else None
51
+ return block if isinstance(block, dict) else None
52
+ except Exception: # noqa: BLE001 — fail open
53
+ logger.debug("admission gate: unreadable for %s", model_key, exc_info=True)
54
+ return None
55
+
56
+
57
+ def refusal_text(model_key: str, block: dict) -> str:
58
+ """The factual refusal: which gate, the recorded verdict, when, which job."""
59
+ return (f"'{model_key}' is {HELD_MARKER}: {block.get('reason') or 'no reason recorded'} "
60
+ f"[integrity={block.get('integrity')}, grade={block.get('grade')}, "
61
+ f"at={block.get('at')}, job={block.get('job')}; "
62
+ f"POST /llm/admission/{model_key}/rerun re-admits, alloc.force=true bypasses]")
63
+
64
+
65
+ def admission_reason(model_key: Optional[str]) -> Optional[str]:
66
+ """The refusal text when ``model_key`` is held, else None."""
67
+ block = admission_block(model_key)
68
+ if not block or block.get("status") != "held":
69
+ return None
70
+ return refusal_text(str(model_key), block)
71
+
72
+
73
+ def is_held(model_key: Optional[str]) -> bool:
74
+ return admission_reason(model_key) is not None
75
+
76
+
77
+ __all__ = ["HELD_MARKER", "admission_block", "admission_reason", "is_held",
78
+ "refusal_text"]
@@ -0,0 +1,401 @@
1
+ """P3.1 — the agent-node registry + dispatch store (SQLite, stdlib-only).
2
+
3
+ Phase 3 gives central a fleet of *agent nodes*: remote P2.7 daemons that
4
+ enroll and heartbeat like GPU workers, and that the operator dispatches tasks
5
+ to. This module is their cross-process store — the same discipline as
6
+ ``comms.shared.SqliteMirror`` (short-lived connections, WAL, one shared db
7
+ file), so it is correct under gunicorn ``--workers 3``: a ``register`` can land
8
+ on process B while a ``heartbeat`` for that node lands on process A, and both
9
+ see the same rows.
10
+
11
+ It differs from the job *mirror* in one deliberate way: this table is the
12
+ SOURCE OF TRUTH, not a best-effort reflection of an in-memory store. There is
13
+ no per-process fallback to degrade to, so a failed write must surface (the
14
+ route returns 500) rather than silently swallow — losing a node registration
15
+ or a dispatched task is never acceptable. (The job mirror can swallow because
16
+ each process still holds the authoritative in-memory Job.)
17
+
18
+ Three concerns, two tables, one db file (shared with the jobs mirror):
19
+
20
+ agent_nodes — one row per enrolled node. Carries the sha256 of the
21
+ node's enroll token (NEVER the plaintext — that is returned
22
+ exactly once, from register), plus the last heartbeat's
23
+ status/current_task/version and a last_seen clock.
24
+ agent_tasks — the dispatch queue. Each dispatch appends a row; a node
25
+ pulls with a monotonic ``since`` cursor (the autoincrement
26
+ seq), so pulls are idempotent and need no delivery bit.
27
+ P3.1b adds a completion channel: ``complete_task`` transitions
28
+ a row queued → done/error with a size-capped ``result`` +
29
+ ``finished_at``. LIFECYCLE CHOICE: the status transition lives
30
+ ONLY on the result route — the pull (``tasks_since``) stays a
31
+ pure, side-effect-free, re-pullable GET (its whole point is
32
+ "no delivery bit"), so there is no ``running`` state written on
33
+ pull; a task is queued until its node reports it done/error,
34
+ and the node's heartbeat (busy + current_task=<seq>) is the
35
+ in-flight signal meanwhile.
36
+
37
+ Tokens: ``agt_`` + hex, sha256-hashed at rest (same shape as the enrollment /
38
+ principal token stores). Authentication is (node_id, token) -> the token must
39
+ hash to THIS node's stored hash and the node must be un-revoked. Fail-closed:
40
+ a missing/mismatched/revoked token authenticates nothing.
41
+
42
+ The db path is ``comms.shared.default_db_path()`` (HUGPY_COMMS_DB, else a
43
+ per-user file) — the same file the job mirror uses. A bare ``AgentNodeStore()``
44
+ in a test can be pointed at a scratch path.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import hashlib
49
+ import json
50
+ import os
51
+ import secrets
52
+ import sqlite3
53
+ import threading
54
+ import time
55
+ import uuid
56
+ from typing import Any, Optional
57
+
58
+ from hugpy_control.shared import default_db_path, retry_on_emfile
59
+
60
+ _TOKEN_PREFIX = "agt_"
61
+ _NODE_PREFIX = "agn_"
62
+ _TASK_PREFIX = "atsk_"
63
+
64
+ # P3.1b — a completed task's stored result is size-capped (a run's answer is
65
+ # operator-defined text; without a cap a node could push an unbounded blob into
66
+ # the shared comms db). Over the cap the result is TRUNCATED (never rejected —
67
+ # refusing would leave the task un-finalizable), with a byte-count marker.
68
+ _MAX_RESULT_BYTES = 65536
69
+ _TASK_TERMINAL = ("done", "error")
70
+
71
+ _SCHEMA_NODES = """
72
+ CREATE TABLE IF NOT EXISTS agent_nodes (
73
+ id TEXT PRIMARY KEY,
74
+ name TEXT NOT NULL,
75
+ host TEXT,
76
+ capabilities TEXT NOT NULL DEFAULT '[]',
77
+ token_hash TEXT NOT NULL,
78
+ status TEXT NOT NULL DEFAULT 'enrolled',
79
+ current_task TEXT,
80
+ version TEXT,
81
+ revoked INTEGER NOT NULL DEFAULT 0,
82
+ created_at REAL NOT NULL,
83
+ last_seen REAL
84
+ );
85
+ """
86
+
87
+ _SCHEMA_TASKS = """
88
+ CREATE TABLE IF NOT EXISTS agent_tasks (
89
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ id TEXT NOT NULL,
91
+ node_id TEXT NOT NULL,
92
+ task TEXT NOT NULL,
93
+ status TEXT NOT NULL DEFAULT 'queued',
94
+ created_at REAL NOT NULL,
95
+ result TEXT,
96
+ finished_at REAL
97
+ );
98
+ """
99
+
100
+ # P3.1b additive columns, backfilled onto a db created before the result route
101
+ # existed (SQLite ADD COLUMN is online + cheap). Applied idempotently in
102
+ # _ensure, guarded by PRAGMA table_info — a fresh db already has them from
103
+ # _SCHEMA_TASKS, so the guard makes this a no-op there.
104
+ _TASK_MIGRATIONS = (
105
+ ("result", "ALTER TABLE agent_tasks ADD COLUMN result TEXT"),
106
+ ("finished_at", "ALTER TABLE agent_tasks ADD COLUMN finished_at REAL"),
107
+ )
108
+
109
+ _SCHEMA_TASK_INDEX = (
110
+ "CREATE INDEX IF NOT EXISTS ix_agent_tasks_node_seq "
111
+ "ON agent_tasks (node_id, seq)"
112
+ )
113
+
114
+
115
+ def _hash(token: str) -> str:
116
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def _loads_list(raw: Any) -> list:
120
+ try:
121
+ v = json.loads(raw) if isinstance(raw, str) else raw
122
+ return list(v) if isinstance(v, (list, tuple)) else []
123
+ except Exception:
124
+ return []
125
+
126
+
127
+ def _cap_result(result: Any) -> Optional[str]:
128
+ """Coerce a reported result to a size-capped string. None stays None; a
129
+ non-str is JSON-encoded (so a node can report structured output); anything
130
+ over ``_MAX_RESULT_BYTES`` is truncated on a UTF-8 boundary with a
131
+ byte-count marker (the task still finalizes — see _MAX_RESULT_BYTES)."""
132
+ if result is None:
133
+ return None
134
+ s = result if isinstance(result, str) else json.dumps(result)
135
+ raw = s.encode("utf-8")
136
+ if len(raw) <= _MAX_RESULT_BYTES:
137
+ return s
138
+ marker = "\n…[truncated %d bytes]" % (len(raw) - _MAX_RESULT_BYTES)
139
+ keep = max(_MAX_RESULT_BYTES - len(marker.encode("utf-8")), 0)
140
+ return raw[:keep].decode("utf-8", "ignore") + marker
141
+
142
+
143
+ class AgentNodeStore:
144
+ """Authoritative, cross-process registry + dispatch queue for agent nodes.
145
+
146
+ Every method opens a short-lived WAL connection — no shared handles across
147
+ threads, no pooling to get wrong (exactly the SqliteMirror discipline).
148
+ Writes/reads are NOT swallowed: this is the source of truth, so a db fault
149
+ propagates to the caller (fail-closed) instead of pretending success.
150
+ """
151
+
152
+ def __init__(self, path: Optional[str] = None) -> None:
153
+ self.path = path or default_db_path()
154
+ self._init_lock = threading.Lock()
155
+ self._initialized = False
156
+
157
+ # -- plumbing ------------------------------------------------------------
158
+ def _connect(self) -> sqlite3.Connection:
159
+ # This store is the SOURCE OF TRUTH for agent-node registration — a
160
+ # failed open here surfaces as a 500 (by design; no per-process
161
+ # fallback). The restart-burst EMFILE is transient, so retry the open
162
+ # (see comms.shared.retry_on_emfile) before letting it propagate.
163
+ conn = retry_on_emfile(lambda: sqlite3.connect(self.path, timeout=5.0))
164
+ conn.row_factory = sqlite3.Row
165
+ conn.execute("PRAGMA journal_mode=WAL")
166
+ conn.execute("PRAGMA synchronous=NORMAL")
167
+ conn.execute("PRAGMA busy_timeout=5000")
168
+ return conn
169
+
170
+ def _ensure(self) -> None:
171
+ if self._initialized:
172
+ return
173
+ with self._init_lock:
174
+ if self._initialized:
175
+ return
176
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
177
+ with self._connect() as conn:
178
+ conn.execute(_SCHEMA_NODES)
179
+ conn.execute(_SCHEMA_TASKS)
180
+ conn.execute(_SCHEMA_TASK_INDEX)
181
+ cols = {r["name"] for r in
182
+ conn.execute("PRAGMA table_info(agent_tasks)")}
183
+ for col, ddl in _TASK_MIGRATIONS:
184
+ if col not in cols:
185
+ conn.execute(ddl)
186
+ self._initialized = True
187
+
188
+ # -- serialization -------------------------------------------------------
189
+ @staticmethod
190
+ def _public_node(row: sqlite3.Row) -> dict[str, Any]:
191
+ """Safe shape for API callers — NEVER includes token_hash/plaintext."""
192
+ return {
193
+ "id": row["id"],
194
+ "name": row["name"],
195
+ "host": row["host"],
196
+ "capabilities": _loads_list(row["capabilities"]),
197
+ "status": row["status"],
198
+ "current_task": row["current_task"],
199
+ "version": row["version"],
200
+ "revoked": bool(row["revoked"]),
201
+ "created_at": row["created_at"],
202
+ "last_seen": row["last_seen"],
203
+ }
204
+
205
+ @staticmethod
206
+ def _task_view(row: sqlite3.Row) -> dict[str, Any]:
207
+ try:
208
+ task = json.loads(row["task"])
209
+ except Exception:
210
+ task = row["task"]
211
+ return {
212
+ "seq": row["seq"],
213
+ "id": row["id"],
214
+ "node_id": row["node_id"],
215
+ "task": task,
216
+ "status": row["status"],
217
+ "created_at": row["created_at"],
218
+ "result": row["result"],
219
+ "finished_at": row["finished_at"],
220
+ }
221
+
222
+ # -- node lifecycle ------------------------------------------------------
223
+ def register(self, *, name: str, host: str = "",
224
+ capabilities: Optional[list] = None) -> dict[str, Any]:
225
+ """Enroll a node. Returns the public node view PLUS a one-time
226
+ ``token`` (the node's enroll credential — shown once, only the hash is
227
+ stored)."""
228
+ self._ensure()
229
+ token = _TOKEN_PREFIX + secrets.token_hex(24)
230
+ node_id = _NODE_PREFIX + uuid.uuid4().hex[:12]
231
+ now = time.time()
232
+ caps = list(capabilities or [])
233
+ with self._connect() as conn:
234
+ conn.execute(
235
+ "INSERT INTO agent_nodes "
236
+ "(id, name, host, capabilities, token_hash, status, "
237
+ " current_task, version, revoked, created_at, last_seen) "
238
+ "VALUES (?, ?, ?, ?, ?, 'enrolled', NULL, NULL, 0, ?, ?)",
239
+ (node_id, str(name), str(host or ""), json.dumps(caps),
240
+ _hash(token), now, now))
241
+ row = conn.execute("SELECT * FROM agent_nodes WHERE id=?",
242
+ (node_id,)).fetchone()
243
+ view = self._public_node(row)
244
+ view["token"] = token # shown ONCE; never stored or returned again
245
+ return view
246
+
247
+ def get(self, node_id: str) -> Optional[dict[str, Any]]:
248
+ self._ensure()
249
+ with self._connect() as conn:
250
+ row = conn.execute("SELECT * FROM agent_nodes WHERE id=?",
251
+ (node_id,)).fetchone()
252
+ return self._public_node(row) if row else None
253
+
254
+ def all(self) -> list[dict[str, Any]]:
255
+ self._ensure()
256
+ with self._connect() as conn:
257
+ rows = conn.execute(
258
+ "SELECT * FROM agent_nodes ORDER BY created_at").fetchall()
259
+ return [self._public_node(r) for r in rows]
260
+
261
+ def authenticate(self, node_id: str, token: Optional[str]) -> bool:
262
+ """True iff ``token`` is THIS node's enroll token and the node is
263
+ un-revoked. Fail-closed on every other path (missing token, wrong
264
+ prefix, unknown node, hash mismatch, revoked)."""
265
+ if not token or not token.startswith(_TOKEN_PREFIX):
266
+ return False
267
+ self._ensure()
268
+ with self._connect() as conn:
269
+ row = conn.execute(
270
+ "SELECT token_hash, revoked FROM agent_nodes WHERE id=?",
271
+ (node_id,)).fetchone()
272
+ if row is None or row["revoked"]:
273
+ return False
274
+ return secrets.compare_digest(str(row["token_hash"]), _hash(token))
275
+
276
+ def heartbeat(self, node_id: str, *, status: Optional[str] = None,
277
+ current_task: Optional[str] = None,
278
+ version: Optional[str] = None) -> Optional[dict[str, Any]]:
279
+ """Record a beat. Returns the updated public view, or None if central
280
+ has no such node (the caller answers 410 -> re-register). Only the
281
+ provided fields are written; last_seen always bumps."""
282
+ self._ensure()
283
+ now = time.time()
284
+ with self._connect() as conn:
285
+ row = conn.execute("SELECT * FROM agent_nodes WHERE id=?",
286
+ (node_id,)).fetchone()
287
+ if row is None:
288
+ return None
289
+ new_status = status if status is not None else row["status"]
290
+ new_task = current_task if current_task is not None \
291
+ else row["current_task"]
292
+ new_version = version if version is not None else row["version"]
293
+ conn.execute(
294
+ "UPDATE agent_nodes SET status=?, current_task=?, version=?, "
295
+ "last_seen=? WHERE id=?",
296
+ (new_status, new_task, new_version, now, node_id))
297
+ row = conn.execute("SELECT * FROM agent_nodes WHERE id=?",
298
+ (node_id,)).fetchone()
299
+ return self._public_node(row)
300
+
301
+ def revoke(self, node_id: str) -> bool:
302
+ self._ensure()
303
+ with self._connect() as conn:
304
+ cur = conn.execute(
305
+ "UPDATE agent_nodes SET revoked=1 WHERE id=?", (node_id,))
306
+ return cur.rowcount > 0
307
+
308
+ # -- dispatch queue ------------------------------------------------------
309
+ def dispatch(self, node_id: str, task: Any) -> Optional[dict[str, Any]]:
310
+ """Queue a task for a node. Returns the queued task view, or None if
311
+ the node is unknown/revoked (the caller answers 404)."""
312
+ self._ensure()
313
+ now = time.time()
314
+ task_id = _TASK_PREFIX + uuid.uuid4().hex[:12]
315
+ with self._connect() as conn:
316
+ node = conn.execute(
317
+ "SELECT revoked FROM agent_nodes WHERE id=?",
318
+ (node_id,)).fetchone()
319
+ if node is None or node["revoked"]:
320
+ return None
321
+ conn.execute(
322
+ "INSERT INTO agent_tasks (id, node_id, task, status, created_at) "
323
+ "VALUES (?, ?, ?, 'queued', ?)",
324
+ (task_id, node_id, json.dumps(task), now))
325
+ row = conn.execute(
326
+ "SELECT * FROM agent_tasks WHERE id=? ORDER BY seq DESC LIMIT 1",
327
+ (task_id,)).fetchone()
328
+ return self._task_view(row)
329
+
330
+ def tasks_since(self, node_id: str,
331
+ since: int = 0) -> list[dict[str, Any]]:
332
+ """Tasks queued for ``node_id`` with seq > ``since``, oldest first.
333
+ The monotonic seq makes the pull idempotent — the node advances its
334
+ cursor to the max seq it has seen and re-requests from there."""
335
+ self._ensure()
336
+ try:
337
+ since_i = int(since or 0)
338
+ except (TypeError, ValueError):
339
+ since_i = 0
340
+ with self._connect() as conn:
341
+ rows = conn.execute(
342
+ "SELECT * FROM agent_tasks WHERE node_id=? AND seq>? "
343
+ "ORDER BY seq ASC", (node_id, since_i)).fetchall()
344
+ return [self._task_view(r) for r in rows]
345
+
346
+ def get_task(self, node_id: str,
347
+ seq: Any) -> Optional[dict[str, Any]]:
348
+ """One task's full view (incl. result/finished_at), scoped to its owning
349
+ node — the operator drill-in for P3.3. None if there is no such seq for
350
+ THIS node (a wrong-node or unknown seq → the route answers 404)."""
351
+ self._ensure()
352
+ try:
353
+ seq_i = int(seq)
354
+ except (TypeError, ValueError):
355
+ return None
356
+ with self._connect() as conn:
357
+ row = conn.execute(
358
+ "SELECT * FROM agent_tasks WHERE seq=? AND node_id=?",
359
+ (seq_i, node_id)).fetchone()
360
+ return self._task_view(row) if row else None
361
+
362
+ def complete_task(self, node_id: str, seq: Any, *, status: str,
363
+ result: Any = None) -> dict[str, Any]:
364
+ """P3.1b — record a node's completion of task ``seq``. Transitions the
365
+ row queued → ``status`` (``done``/``error``), storing the size-capped
366
+ result + ``finished_at``. Scoped to the owning node (fail-closed).
367
+
368
+ Returns a small outcome dict the route maps to a status code:
369
+ * ``{"ok": True, "task": <view>}`` finalized → 200
370
+ * ``{"ok": False, "reason": "not_found"}`` no such seq for
371
+ this node (unknown/other node's task) → 404
372
+ * ``{"ok": False, "reason": "conflict", "task":…}`` already finalized
373
+ → 409; the FIRST report wins and is NOT overwritten (idempotent-safe
374
+ re-post; a node treats 200 AND 409 as 'recorded, advance cursor')."""
375
+ self._ensure()
376
+ try:
377
+ seq_i = int(seq)
378
+ except (TypeError, ValueError):
379
+ return {"ok": False, "reason": "not_found"}
380
+ status = status if status in _TASK_TERMINAL else "error"
381
+ capped = _cap_result(result)
382
+ now = time.time()
383
+ with self._connect() as conn:
384
+ row = conn.execute("SELECT * FROM agent_tasks WHERE seq=?",
385
+ (seq_i,)).fetchone()
386
+ if row is None or row["node_id"] != node_id:
387
+ return {"ok": False, "reason": "not_found"}
388
+ if row["status"] in _TASK_TERMINAL:
389
+ return {"ok": False, "reason": "conflict",
390
+ "task": self._task_view(row)}
391
+ conn.execute(
392
+ "UPDATE agent_tasks SET status=?, result=?, finished_at=? "
393
+ "WHERE seq=?", (status, capped, now, seq_i))
394
+ row = conn.execute("SELECT * FROM agent_tasks WHERE seq=?",
395
+ (seq_i,)).fetchone()
396
+ return {"ok": True, "task": self._task_view(row)}
397
+
398
+
399
+ # Module singleton (mirrors job_store / principal_store / token_store). Points
400
+ # at the shared comms db by default; tests construct their own with a path.
401
+ agent_node_store = AgentNodeStore()
@@ -0,0 +1,125 @@
1
+ """Central's ARCHIVE gate — refuse every placement/routing choice that would land
2
+ on a model the operator marked for archive (hugpy_storage.archive_mark).
3
+
4
+ The mark lives on the model's own ``hugpy.json["archive"]``; this module reads
5
+ it through the PERSISTED marker aspect every catalog surface already reads
6
+ (``lookup_physical(.., ASPECT_MARKER)``, derived live on a miss) — the same
7
+ path ``admission_gate`` uses — so a gate check is a dict lookup, not a file
8
+ read per request.
9
+
10
+ Distinct from the operator BLOCKLIST (``blocklist.py``, settings-store backed,
11
+ reversible routing override) and from ADMISSION (a machine verdict): the mark
12
+ is the operator's recorded intent to move the model out of the live set. It is
13
+ enforced at least as strictly as a block — resolver, designation, warm,
14
+ provisioning — and, unlike the admission hold, ``alloc.force`` never bypasses
15
+ it. The refusal text is :func:`hugpy_storage.archive_mark.archive_text` (who
16
+ marked it, when, why) and carries ``ARCHIVE_MARKER`` so the engine's cold-hold
17
+ classifier treats it as PERMANENT.
18
+
19
+ Fail-open like the blocklist: a gate that cannot read the marker answers
20
+ "not marked".
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import threading
26
+ import time
27
+ from typing import Optional
28
+
29
+ from hugpy_storage.archive_mark import (ARCHIVE_KEY, ARCHIVE_MARKER, archive_text,
30
+ archive_view, is_marked)
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _KEYS_TTL_S = 5.0
35
+ _keys_lock = threading.Lock()
36
+ _keys_cache: dict = {"at": 0.0, "keys": frozenset()}
37
+
38
+
39
+ def _row_and_key(model_key: str):
40
+ from hugpy_fleet.central import workers as _w
41
+ key = _w._canonical_registry_key(model_key)
42
+ return key, _w._registry_row(key)
43
+
44
+
45
+ def _marker_of(key: str, row: dict) -> dict:
46
+ from hugpy_storage.model_physical import ASPECT_MARKER, lookup_physical
47
+ fields, state = lookup_physical(key, row, ASPECT_MARKER)
48
+ if state != "fresh":
49
+ from hugpy_storage.console.model_physical import marker_fields
50
+ fields = marker_fields(row, key)
51
+ marker = (fields or {}).get("hugpy_marker") or {}
52
+ return marker if isinstance(marker, dict) else {}
53
+
54
+
55
+ def archive_block(model_key: Optional[str]) -> Optional[dict]:
56
+ """The model's archive block (``{marked, at, by, reason}``) when marked, else None."""
57
+ if not model_key:
58
+ return None
59
+ try:
60
+ key, row = _row_and_key(str(model_key))
61
+ if row is None:
62
+ return None
63
+ block = _marker_of(key, row).get(ARCHIVE_KEY)
64
+ return dict(block) if is_marked(block) else None
65
+ except Exception: # noqa: BLE001 — fail open
66
+ logger.debug("archive gate: unreadable for %s", model_key, exc_info=True)
67
+ return None
68
+
69
+
70
+ def archive_reason(model_key: Optional[str]) -> Optional[str]:
71
+ """``'<key>' is marked for archive by <by> at <at>: <reason>`` or None."""
72
+ block = archive_block(model_key)
73
+ if not block:
74
+ return None
75
+ return f"'{model_key}' is {archive_text(block)}"
76
+
77
+
78
+ def is_archived(model_key: Optional[str]) -> bool:
79
+ return archive_block(model_key) is not None
80
+
81
+
82
+ def refusal(model_key: str) -> Optional[dict]:
83
+ """The structured refusal body for a route (HTTP 409), or None when not
84
+ marked: ``{"error": <archive_reason>, "archive": {marked, at, by, reason}}``."""
85
+ block = archive_block(model_key)
86
+ if not block:
87
+ return None
88
+ return {"error": f"'{model_key}' is {archive_text(block)}",
89
+ "archive": archive_view(block)}
90
+
91
+
92
+ def archived_keys(*, fresh: bool = False) -> frozenset:
93
+ """Every catalog key currently marked for archive (5 s process cache —
94
+ warm sweeps and heartbeat replies call this; a mark/unmark in this process
95
+ drops the cache at once via :func:`invalidate`)."""
96
+ now = time.monotonic()
97
+ with _keys_lock:
98
+ if not fresh and now - _keys_cache["at"] < _KEYS_TTL_S:
99
+ return _keys_cache["keys"]
100
+ out = set()
101
+ try:
102
+ from hugpy_engine.config.models.models_config import get_models_dict
103
+ for key, row in (get_models_dict(dict_return=True) or {}).items():
104
+ mk = (row or {}).get("model_key") or key
105
+ try:
106
+ if is_marked(_marker_of(mk, row).get(ARCHIVE_KEY)):
107
+ out.add(mk)
108
+ out.add(key)
109
+ except Exception: # noqa: BLE001 — one unreadable row is "not marked"
110
+ continue
111
+ except Exception: # noqa: BLE001 — fail open
112
+ logger.debug("archive gate: catalog enumerate failed", exc_info=True)
113
+ keys = frozenset(out)
114
+ with _keys_lock:
115
+ _keys_cache.update(at=time.monotonic(), keys=keys)
116
+ return keys
117
+
118
+
119
+ def invalidate() -> None:
120
+ with _keys_lock:
121
+ _keys_cache.update(at=0.0, keys=frozenset())
122
+
123
+
124
+ __all__ = ["ARCHIVE_MARKER", "archive_block", "archive_reason", "archived_keys",
125
+ "invalidate", "is_archived", "refusal"]