Workers screen shows each agent's version and protocol, and who is behind

The hello carries the fluksio-worker version. An agent refused for an older
protocol stays listed as Needs upgrade until it attaches again. Docs gain an
Upgrading workers section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 11:09:54 +02:00
co-authored by Claude Opus 5.5
parent 5f05d3f8b4
commit a6736bf484
7 changed files with 124 additions and 16 deletions
+30 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import logging
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -55,6 +56,11 @@ TOKEN_DAYS = 365
#: The sockets attached with each token, by `jti`, so revoking one drops them.
_attached: dict[str, set[WebSocket]] = {}
#: Workers refused for an older protocol, by name, until one attaches again.
#: Such an agent exits on the refusal, so this is the one place left that says
#: which host still needs `pip install -U fluksio-worker`.
_behind: dict[str, WorkerInfo] = {}
class WorkerInfo(BaseModel):
name: str
@@ -68,6 +74,11 @@ class WorkerInfo(BaseModel):
cpus: int = 1
gpus: int = 0
ram_mb: int | None = None
protocol: int = 0
#: The fluksio-worker it runs; empty for an agent too old to say.
version: str = ""
#: Refused for speaking an older protocol than this engine, so not attached.
behind: bool = False
class TokenRequest(BaseModel):
@@ -109,9 +120,11 @@ def read_workers(request: Request) -> Any:
cpus=worker.inventory.cpus,
gpus=worker.inventory.gpus,
ram_mb=worker.inventory.ram_mb,
protocol=int(worker.info.get("protocol") or 0),
version=str(worker.info.get("version") or ""),
)
for worker in _hub(request.app).workers()
]
] + list(_behind.values())
class ResourceLevel(BaseModel):
@@ -274,7 +287,19 @@ async def _attached_session(websocket: WebSocket, claims: dict[str, Any]) -> Non
await websocket.close(code=1002)
return
if hello.get("op") != "hello" or int(hello.get("protocol", 0)) != PROTOCOL:
protocol = int(hello.get("protocol", 0))
if hello.get("op") != "hello" or protocol != PROTOCOL:
if hello.get("op") == "hello" and protocol < PROTOCOL:
name = str(claims.get("sub") or hello.get("name") or "worker")
_behind[name] = WorkerInfo(
name=name,
labels=sorted(str(label) for label in claims.get("labels") or []),
last_seen=time.time(),
python=str(hello.get("python") or ""),
protocol=protocol,
version=str(hello.get("version") or ""),
behind=True,
)
await websocket.send_json(
{"op": "refused", "reason": f"this engine speaks protocol {PROTOCOL}"}
)
@@ -308,10 +333,13 @@ async def _attached_session(websocket: WebSocket, claims: dict[str, Any]) -> Non
info={
"python": hello.get("python"),
"venv_digest": hello.get("venv_digest"),
"protocol": protocol,
"version": hello.get("version"),
},
inventory=WorkerInventory.from_hello(hello.get("inventory")),
)
hub.attach(worker)
_behind.pop(name, None)
await websocket.send_json({"op": "welcome", "protocol": PROTOCOL, "name": name})
try:
+21
View File
@@ -38,6 +38,27 @@ def test_the_token_names_the_worker_and_its_labels(
assert [(w["name"], w["labels"]) for w in attached] == [("gpu-box", ["gpu"])]
def test_the_list_names_each_version_and_who_is_behind(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
issued = mint(client, superuser_token_headers)
url = f"{PREFIX}/attach?token={issued['token']}"
def listed() -> list[tuple[object, ...]]:
answer = client.get(PREFIX, headers=superuser_token_headers).json()
return [(w["protocol"], w["version"], w["behind"]) for w in answer]
with client.websocket_connect(url) as ws:
ws.send_json({**hello(), "protocol": PROTOCOL - 1, "version": "0.1.5"})
assert ws.receive_json()["op"] == "refused"
assert listed() == [(PROTOCOL - 1, "0.1.5", True)]
with client.websocket_connect(url) as ws:
ws.send_json({**hello(), "version": "0.2.0"})
ws.receive_json()
assert listed() == [(PROTOCOL, "0.2.0", False)]
def test_a_worker_cannot_claim_a_label_its_token_lacks(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None: