feat(perception): connect scoped host telemetry to recoverable profile lifecycle
This commit is contained in:
@@ -113,8 +113,9 @@ class StreamingLifecycle:
|
||||
if self.continuity is None:
|
||||
self.request_stop("worker-not-ready")
|
||||
raise
|
||||
self.continuity.pause("worker-telemetry")
|
||||
self.mailbox.pause()
|
||||
if self.state == GraphState.RUNNING:
|
||||
self.continuity.pause("worker-telemetry")
|
||||
self.mailbox.pause()
|
||||
if not allow_unavailable:
|
||||
raise StreamSuspended(str(exc)) from exc
|
||||
except WorkerReadinessError:
|
||||
@@ -142,12 +143,29 @@ class StreamingLifecycle:
|
||||
if self.continuity is None:
|
||||
self.request_stop("worker-not-ready")
|
||||
raise
|
||||
self.continuity.pause("worker-telemetry")
|
||||
self.mailbox.pause()
|
||||
if self.state == GraphState.RUNNING:
|
||||
self.continuity.pause("worker-telemetry")
|
||||
self.mailbox.pause()
|
||||
except WorkerReadinessError:
|
||||
self.request_stop("worker-not-ready")
|
||||
raise
|
||||
|
||||
def attach_readiness(self, monitor: WorkerReadinessMonitor) -> None:
|
||||
"""Trusted bootstrap after acquiring the local lease, BEFORE any child.
|
||||
|
||||
Allows a real collector to report the acquired owner, not a fabricated
|
||||
pre-lease claim. No GPU work may be spawned by this bootstrap adapter
|
||||
before this call. Installation is one-shot and cannot weaken a monitor.
|
||||
"""
|
||||
with self._lock:
|
||||
self._check(self.start, starting=True)
|
||||
if self.state != GraphState.STARTING or self._children or self._readiness is not None:
|
||||
raise WorkerReadinessError("readiness must be attached once before model startup")
|
||||
if self.continuity is not None and not monitor.recoverable:
|
||||
raise WorkerReadinessError("recoverable input requires recoverable readiness")
|
||||
monitor.check(self.start, now_monotonic_ns=self._clock_ns(), require_warmup=False)
|
||||
self._readiness = monitor
|
||||
|
||||
def renew(self, start: StreamStart) -> None:
|
||||
with self._lock:
|
||||
self._check(start, starting=True, allow_unavailable=True)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Bounded Worker-local host observation channel, separate from sensor ingress.
|
||||
|
||||
The launcher gives the controller a private request directory and a SEPARATE
|
||||
read-only response mount. Only the trusted host collector writes responses;
|
||||
neither a Docker socket nor host commands are exposed to the AI container.
|
||||
This filesystem boundary is not authentication for a network/GCS connection.
|
||||
|
||||
One outstanding nonce requires a new host read after each request. Observation
|
||||
time is the request's LOCAL monotonic start (a conservative lower bound), never
|
||||
the reply's arrival or the foreign Windows clock. Delays cannot rejuvenate data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .realtime_contract import RealtimeContractError, StreamStart, _digest, _integer
|
||||
from .worker_operating_envelope import WorkerSnapshot
|
||||
|
||||
CONTROL_SCHEMA = "missioncore.worker-host-observation/v1"
|
||||
MAX_CONTROL_BYTES = 16384
|
||||
INVENTORY_SCOPE = "docker-gpu-access"
|
||||
_FACT_FIELDS = {
|
||||
"worker_id",
|
||||
"container_id",
|
||||
"image_sha256",
|
||||
"cpu_limit_millicores",
|
||||
"memory_limit_mib",
|
||||
"gpu_name",
|
||||
"driver_version",
|
||||
"sm_clock_mhz",
|
||||
"memory_clock_mhz",
|
||||
"gpu_telemetry_available",
|
||||
"inventory_complete",
|
||||
"competing_gpu_clients",
|
||||
"inventory_scope",
|
||||
"host_process_inventory",
|
||||
}
|
||||
|
||||
|
||||
def activation_digest(start: StreamStart) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(start.to_dict(), sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
class WorkerControlChannel:
|
||||
"""Single-reader/single-writer adapter owned by the local controller thread.
|
||||
|
||||
Paths and target container prefix come from the launcher, never a source
|
||||
packet. Container identity is pinned on the first response and then exact.
|
||||
Only 12..64 lowercase hex Docker IDs are accepted (not names).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
start: StreamStart,
|
||||
requests: Path,
|
||||
responses: Path,
|
||||
*,
|
||||
container_id: str,
|
||||
clock_domain_id: str,
|
||||
maximum_age_ms: int = 1000,
|
||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||
) -> None:
|
||||
if not 12 <= len(container_id) <= 64 or any(
|
||||
c not in "0123456789abcdef" for c in container_id
|
||||
):
|
||||
raise ValueError("launcher must select a Docker ID, not a name")
|
||||
if requests.resolve() == responses.resolve():
|
||||
raise ValueError("request and read-only response mounts must be separate")
|
||||
_integer(maximum_age_ms, "control maximum age", minimum=1)
|
||||
self.start, self.activation = start, activation_digest(start)
|
||||
self.requests, self.responses = requests, responses
|
||||
self.container_id, self.clock_domain_id = container_id, clock_domain_id
|
||||
self.clock_ns, self.maximum_age_ns = clock_ns, maximum_age_ms * 1_000_000
|
||||
self.pending: dict[str, Any] | None = None
|
||||
self.requested_ns = 0
|
||||
self.sequence = self.accepted = self.expired = 0
|
||||
self.last_error: str | None = None
|
||||
self.last_roundtrip_ms: float | None = None
|
||||
|
||||
def request(self) -> None:
|
||||
if self.pending is not None:
|
||||
return
|
||||
self.sequence += 1
|
||||
self.requested_ns = self.clock_ns()
|
||||
self.pending = {
|
||||
"schema_version": CONTROL_SCHEMA,
|
||||
"activation_sha256": self.activation,
|
||||
"nonce": secrets.token_hex(32),
|
||||
"sequence": self.sequence,
|
||||
}
|
||||
# Two fixed files; no queue or per-sample history. Requests contain no
|
||||
# executable command, secret, source data or caller-selected target.
|
||||
path = self.requests / "request.json"
|
||||
temporary = self.requests / "request.tmp"
|
||||
temporary.write_text(json.dumps(self.pending), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
|
||||
def poll(self, *, owner: tuple[str, int], warmup_complete: bool) -> WorkerSnapshot | None:
|
||||
if self.pending is None:
|
||||
return None
|
||||
now = self.clock_ns()
|
||||
if now < self.requested_ns:
|
||||
raise RealtimeContractError("controller clock moved backwards")
|
||||
try:
|
||||
with (self.responses / "response.json").open("rb") as stream:
|
||||
raw = stream.read(MAX_CONTROL_BYTES + 1)
|
||||
if len(raw) > MAX_CONTROL_BYTES:
|
||||
raise ValueError("size")
|
||||
reply = json.loads(raw.decode("utf-8-sig"))
|
||||
if not isinstance(reply, dict) or set(reply) != set(self.pending) | {"facts"}:
|
||||
raise ValueError("schema")
|
||||
if any(reply[key] != value for key, value in self.pending.items()):
|
||||
# Old/different activation cannot poison or refresh this owner.
|
||||
raise ValueError("binding")
|
||||
snapshot = self._snapshot(reply["facts"], owner, warmup_complete)
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
self.last_error = "response-unavailable-or-invalid"
|
||||
if now - self.requested_ns > self.maximum_age_ns:
|
||||
self.expired += 1
|
||||
self.pending = None
|
||||
return None
|
||||
self.pending = None
|
||||
self.last_roundtrip_ms = (now - self.requested_ns) / 1_000_000
|
||||
if now - self.requested_ns > self.maximum_age_ns:
|
||||
self.expired += 1
|
||||
self.last_error = "response-expired"
|
||||
# Preserve known conflicts even in a delayed reply; the monitor
|
||||
# separately rejects its OLD age. It must never become fresh.
|
||||
else:
|
||||
self.last_error = None
|
||||
self.accepted += 1
|
||||
return snapshot
|
||||
|
||||
def _snapshot(self, facts: object, owner: tuple[str, int], warmup: bool) -> WorkerSnapshot:
|
||||
if not isinstance(facts, dict) or set(facts) != _FACT_FIELDS:
|
||||
raise ValueError("facts schema")
|
||||
target = facts["container_id"]
|
||||
if target is not None:
|
||||
_digest(target, "container ID")
|
||||
if not target.startswith(self.container_id):
|
||||
# Do not disguise a changed container as a missing observation.
|
||||
raise ContainerIdentityConflict("host observed another container")
|
||||
if (
|
||||
facts["inventory_scope"] != INVENTORY_SCOPE
|
||||
or facts["host_process_inventory"] != "unproved"
|
||||
):
|
||||
raise ValueError("unsupported inventory claim")
|
||||
if type(facts["inventory_complete"]) is not bool:
|
||||
raise ValueError("inventory completeness")
|
||||
clients = facts["competing_gpu_clients"]
|
||||
if not isinstance(clients, list) or len(clients) > 64:
|
||||
raise ValueError("inventory bound")
|
||||
# Partial inventory may prove a conflict, but cannot prove its absence.
|
||||
competitors = tuple(clients) if clients or facts["inventory_complete"] else None
|
||||
snapshot = WorkerSnapshot(
|
||||
worker_id=facts["worker_id"],
|
||||
clock_domain_id=self.clock_domain_id,
|
||||
observed_monotonic_ns=self.requested_ns,
|
||||
gpu_name=facts["gpu_name"],
|
||||
driver_version=facts["driver_version"],
|
||||
image_sha256=facts["image_sha256"] if target else None,
|
||||
effective_config_sha256=self.start.effective_config_sha256,
|
||||
cpu_limit_millicores=facts["cpu_limit_millicores"],
|
||||
memory_limit_mib=facts["memory_limit_mib"],
|
||||
sm_clock_mhz=facts["sm_clock_mhz"],
|
||||
memory_clock_mhz=facts["memory_clock_mhz"],
|
||||
gpu_owner_run_id=owner[0],
|
||||
lease_generation=owner[1],
|
||||
competing_gpu_clients=competitors,
|
||||
warmup_complete=warmup,
|
||||
inventory_scope=INVENTORY_SCOPE,
|
||||
gpu_telemetry_available=facts["gpu_telemetry_available"],
|
||||
)
|
||||
if target is not None:
|
||||
self.container_id = target
|
||||
return snapshot
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": CONTROL_SCHEMA,
|
||||
"requests": self.sequence,
|
||||
"accepted": self.accepted,
|
||||
"expired": self.expired,
|
||||
"pending": self.pending is not None,
|
||||
"last_error": self.last_error,
|
||||
"last_roundtrip_ms": self.last_roundtrip_ms,
|
||||
"container_id": self.container_id,
|
||||
"inventory_scope": INVENTORY_SCOPE,
|
||||
"host_process_inventory": "unproved",
|
||||
"network_authenticated": False,
|
||||
"realtime_qualified": False,
|
||||
}
|
||||
|
||||
|
||||
class ContainerIdentityConflict(RuntimeError):
|
||||
"""Trusted host returned a known different container; terminal, not a lag."""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Local controller adapter: host I/O never holds lifecycle locks or renews lease."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .streaming_continuity import StreamSuspended
|
||||
from .streaming_lifecycle import StreamingLifecycle
|
||||
from .worker_control import ContainerIdentityConflict, WorkerControlChannel
|
||||
from .worker_operating_envelope import WorkerOperatingEnvelope, WorkerSnapshot
|
||||
from .worker_readiness import ReadinessMode, WorkerReadinessMonitor, WorkerTelemetryUnavailable
|
||||
|
||||
|
||||
class WorkerControlPump:
|
||||
def __init__(
|
||||
self,
|
||||
runtime: StreamingLifecycle,
|
||||
channel: WorkerControlChannel,
|
||||
envelope: WorkerOperatingEnvelope,
|
||||
*,
|
||||
mode: ReadinessMode,
|
||||
bootstrap_seconds: float = 5.0,
|
||||
) -> None:
|
||||
self.runtime, self.channel = runtime, channel
|
||||
self.stop_event, self.warmed, self.warm_observed = (
|
||||
threading.Event(),
|
||||
threading.Event(),
|
||||
threading.Event(),
|
||||
)
|
||||
self.io_failures = 0
|
||||
self.error: str | None = None
|
||||
deadline = time.monotonic() + bootstrap_seconds
|
||||
initial = None
|
||||
while initial is None and time.monotonic() < deadline:
|
||||
initial = self._poll()
|
||||
if initial is None:
|
||||
self.stop_event.wait(0.025)
|
||||
if initial is None:
|
||||
raise WorkerTelemetryUnavailable(
|
||||
"host collector bootstrap timed out; no models started"
|
||||
)
|
||||
runtime.attach_readiness(
|
||||
WorkerReadinessMonitor(
|
||||
runtime.start,
|
||||
envelope,
|
||||
initial,
|
||||
mode=mode,
|
||||
clock_domain_id=channel.clock_domain_id,
|
||||
now_monotonic_ns=channel.clock_ns(),
|
||||
recoverable=True,
|
||||
)
|
||||
)
|
||||
self.thread = threading.Thread(target=self._run, name="worker-host-observer", daemon=True)
|
||||
runtime.track_thread(self.thread)
|
||||
self.thread.start()
|
||||
|
||||
def _poll(self) -> WorkerSnapshot | None:
|
||||
# Owner/config/warmup come from THIS controller, not a host JSON reply
|
||||
# or a source heartbeat. The lease is checked again on observe_worker.
|
||||
self.runtime.check_current(self.runtime.start, starting=True)
|
||||
try:
|
||||
self.channel.request()
|
||||
return self.channel.poll(
|
||||
owner=(self.runtime.start.run_id, self.runtime.start.lease_generation),
|
||||
warmup_complete=self.warmed.is_set(),
|
||||
)
|
||||
except OSError:
|
||||
self.io_failures += 1
|
||||
return None # The unchanged observation expires independently.
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
while not self.stop_event.wait(0.025) and not self.runtime.stop_event.is_set():
|
||||
observed = self._poll()
|
||||
if observed is not None:
|
||||
self.runtime.observe_worker(self.runtime.start, observed)
|
||||
if observed.warmup_complete:
|
||||
self.warm_observed.set()
|
||||
self.stop_event.wait(0.2)
|
||||
except Exception as exc:
|
||||
self.error = type(exc).__name__
|
||||
self.runtime.request_stop(
|
||||
"worker-container-conflict"
|
||||
if isinstance(exc, ContainerIdentityConflict)
|
||||
else "worker-control-failed"
|
||||
)
|
||||
|
||||
def ready(self, seconds: float = 5.0) -> None:
|
||||
self.warmed.set()
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if self.warm_observed.wait(0.025):
|
||||
try:
|
||||
self.runtime.ready()
|
||||
return
|
||||
except StreamSuspended:
|
||||
self.stop_event.wait(0.025)
|
||||
raise WorkerTelemetryUnavailable("post-warmup host readiness not established")
|
||||
|
||||
def close(self) -> bool:
|
||||
self.stop_event.set()
|
||||
self.thread.join(timeout=1)
|
||||
return not self.thread.is_alive()
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {**self.channel.snapshot(), "io_failures": self.io_failures, "error": self.error}
|
||||
@@ -31,9 +31,13 @@ class WorkerOperatingEnvelope:
|
||||
minimum_sm_clock_mhz: int
|
||||
minimum_memory_clock_mhz: int
|
||||
maximum_snapshot_age_ms: int = 1000
|
||||
# A Docker inventory is NOT an audit of native host/WSL GPU processes.
|
||||
inventory_scope: str = "host-compute"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.envelope_id, "envelope_id")
|
||||
if self.inventory_scope not in ("host-compute", "docker-gpu-access"):
|
||||
raise RealtimeContractError("unsupported inventory scope")
|
||||
for field in ("gpu_name", "driver_version"):
|
||||
value = getattr(self, field)
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > 160:
|
||||
@@ -66,9 +70,15 @@ class WorkerSnapshot:
|
||||
lease_generation: int | None
|
||||
competing_gpu_clients: tuple[str, ...] | None
|
||||
warmup_complete: bool | None
|
||||
inventory_scope: str = "host-compute"
|
||||
gpu_telemetry_available: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.worker_id, "worker_id")
|
||||
if self.inventory_scope not in ("host-compute", "docker-gpu-access"):
|
||||
raise RealtimeContractError("unsupported inventory scope")
|
||||
if type(self.gpu_telemetry_available) is not bool:
|
||||
raise RealtimeContractError("GPU telemetry availability must be boolean")
|
||||
_identifier(self.clock_domain_id, "clock_domain_id")
|
||||
_integer(self.observed_monotonic_ns, "observed_monotonic_ns")
|
||||
for field in ("image_sha256", "effective_config_sha256"):
|
||||
@@ -129,6 +139,10 @@ def operating_envelope_failures(
|
||||
if age < 0:
|
||||
raise RealtimeContractError("worker snapshot is from the future")
|
||||
failures = []
|
||||
if observed.inventory_scope != expected.inventory_scope:
|
||||
failures.append("inventory-scope-mismatch")
|
||||
if not observed.gpu_telemetry_available:
|
||||
failures.append("gpu-telemetry-unavailable")
|
||||
if age > expected.maximum_snapshot_age_ms * 1_000_000:
|
||||
failures.append("worker-snapshot-expired")
|
||||
for field in ("worker_id", "image_sha256", "effective_config_sha256"):
|
||||
|
||||
@@ -107,7 +107,11 @@ class WorkerReadinessMonitor:
|
||||
if self.recoverable:
|
||||
# Missing metrics are not a proof that another process owns GPU.
|
||||
# Known identity/conflict/clock failures still fence immediately.
|
||||
uncertain = {"worker-snapshot-expired", "warmup-not-complete"}
|
||||
uncertain = {
|
||||
"worker-snapshot-expired",
|
||||
"warmup-not-complete",
|
||||
"gpu-telemetry-unavailable",
|
||||
}
|
||||
for field in ("image_sha256", "effective_config_sha256"):
|
||||
if getattr(self._observed, field) is None:
|
||||
uncertain.add(f"{field}-mismatch-or-unknown")
|
||||
|
||||
Reference in New Issue
Block a user