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.
- hugpy_fleet/__init__.py +53 -0
- hugpy_fleet/central/__init__.py +0 -0
- hugpy_fleet/central/admission_gate.py +78 -0
- hugpy_fleet/central/agent_nodes.py +401 -0
- hugpy_fleet/central/archive_gate.py +125 -0
- hugpy_fleet/central/blocklist.py +267 -0
- hugpy_fleet/central/calibration.py +390 -0
- hugpy_fleet/central/config.py +101 -0
- hugpy_fleet/central/enrollment_tokens.py +220 -0
- hugpy_fleet/central/evict_policy.py +109 -0
- hugpy_fleet/central/evictions.py +1007 -0
- hugpy_fleet/central/feeds.py +240 -0
- hugpy_fleet/central/heartbeat_db.py +201 -0
- hugpy_fleet/central/model_groups.py +389 -0
- hugpy_fleet/central/model_metrics.py +901 -0
- hugpy_fleet/central/oracle_adapters.py +72 -0
- hugpy_fleet/central/peers.py +124 -0
- hugpy_fleet/central/phone_brick_store.py +330 -0
- hugpy_fleet/central/pid_attribution.py +249 -0
- hugpy_fleet/central/placement.py +509 -0
- hugpy_fleet/central/pool_guard.py +167 -0
- hugpy_fleet/central/priority_group_settings.py +269 -0
- hugpy_fleet/central/priority_groups.py +482 -0
- hugpy_fleet/central/task_templates.py +323 -0
- hugpy_fleet/central/worker_http.py +434 -0
- hugpy_fleet/central/workers.py +6178 -0
- hugpy_fleet/doctrine/__init__.py +69 -0
- hugpy_fleet/doctrine/doctor.py +475 -0
- hugpy_fleet/doctrine/doctrine.py +624 -0
- hugpy_fleet/doctrine/fleet_runbook.json +95 -0
- hugpy_fleet/doctrine/runbook.py +33 -0
- hugpy_fleet/fleet_manager/__init__.py +46 -0
- hugpy_fleet/fleet_manager/templates.py +565 -0
- hugpy_fleet/gguf_worker/__init__.py +10 -0
- hugpy_fleet/gguf_worker/__main__.py +4 -0
- hugpy_fleet/gguf_worker/agent.py +852 -0
- hugpy_fleet/phone_brick/__init__.py +64 -0
- hugpy_fleet/phone_brick/__main__.py +135 -0
- hugpy_fleet/phone_brick/analyze.py +136 -0
- hugpy_fleet/phone_brick/bootstrap.sh +141 -0
- hugpy_fleet/phone_brick/client.py +75 -0
- hugpy_fleet/phone_brick/consensus.py +35 -0
- hugpy_fleet/phone_brick/detector.py +227 -0
- hugpy_fleet/phone_brick/http.py +17 -0
- hugpy_fleet/phone_brick/orchestrator.py +148 -0
- hugpy_fleet/phone_brick/protocol.py +96 -0
- hugpy_fleet/phone_brick/registration.py +134 -0
- hugpy_fleet/phone_brick/rendering.py +43 -0
- hugpy_fleet/phone_brick/rpc_backend.py +359 -0
- hugpy_fleet/phone_brick/schemas.py +196 -0
- hugpy_fleet/phone_brick/worker.py +244 -0
- hugpy_fleet/phone_brick_orchestrator/__init__.py +11 -0
- hugpy_fleet/phone_brick_orchestrator/runner.py +141 -0
- hugpy_fleet/py.typed +0 -0
- hugpy_fleet/toks_report.py +87 -0
- hugpy_fleet/wire.py +173 -0
- hugpy_fleet/worker/__init__.py +7 -0
- hugpy_fleet/worker/__main__.py +4 -0
- hugpy_fleet/worker/_studio_subproc.py +286 -0
- hugpy_fleet/worker/agent.py +12733 -0
- hugpy_fleet/worker/aggregate.py +596 -0
- hugpy_fleet/worker/aptitude/__init__.py +15 -0
- hugpy_fleet/worker/aptitude/cases.py +364 -0
- hugpy_fleet/worker/aptitude/parse.py +73 -0
- hugpy_fleet/worker/aptitude/score.py +401 -0
- hugpy_fleet/worker/aptitude/selftest.py +227 -0
- hugpy_fleet/worker/bootstrap.sh +214 -0
- hugpy_fleet/worker/budget.py +835 -0
- hugpy_fleet/worker/comfy_ledger.py +221 -0
- hugpy_fleet/worker/comfy_watchdog.py +407 -0
- hugpy_fleet/worker/deploy/install.sh +54 -0
- hugpy_fleet/worker/environment_report.py +500 -0
- hugpy_fleet/worker/flex.py +689 -0
- hugpy_fleet/worker/gen_gate.py +323 -0
- hugpy_fleet/worker/imports.py +74 -0
- hugpy_fleet/worker/install.py +397 -0
- hugpy_fleet/worker/logs.py +233 -0
- hugpy_fleet/worker/pid_registry.py +601 -0
- hugpy_fleet/worker/plugins.py +196 -0
- hugpy_fleet/worker/slot_child.py +26 -0
- hugpy_fleet/worker/storage_hooks.py +68 -0
- hugpy_fleet/worker/studio_render.py +357 -0
- hugpy_fleet/worker/studio_reserve.py +161 -0
- hugpy_fleet-0.2.0a0.dist-info/METADATA +43 -0
- hugpy_fleet-0.2.0a0.dist-info/RECORD +89 -0
- hugpy_fleet-0.2.0a0.dist-info/WHEEL +5 -0
- hugpy_fleet-0.2.0a0.dist-info/entry_points.txt +4 -0
- hugpy_fleet-0.2.0a0.dist-info/licenses/LICENSE +41 -0
- hugpy_fleet-0.2.0a0.dist-info/top_level.txt +1 -0
hugpy_fleet/__init__.py
ADDED
|
@@ -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"]
|