Service node: a server kept running on a worker (openpanel V2)

- `service` node type: command, port, worker label, health path, env and
  weights (an artifact digest the worker fetches as {weights}). Its flow
  starts it; it restarts with backoff, reads degraded while starting or
  without a worker and down after three starts that never got healthy, and
  publishes http://<worker>:<port> once /health answers 200, "" when it ends.
- Worker protocol 3: a `serve` call, every call in its own process group,
  killed when the engine ends the call, the socket drops or the agent gets
  SIGTERM. `--service-host` overrides the address the engine sees.
- Services and compiles take no `--parallel` slot, and a flow's detail is
  built off the event loop, so a save no longer stalls the engine.
This commit is contained in:
Melvin Strobl
2026-10-19 18:43:04 +02:00
parent 80e0201612
commit 85dad39973
16 changed files with 757 additions and 34 deletions
+60
View File
@@ -1,6 +1,10 @@
"""The remote worker's own guards: where its token goes, and what node code gets."""
import argparse
import asyncio
import json
import time
from pathlib import Path
import pytest
from fluksio_worker import agent
@@ -75,3 +79,59 @@ def test_only_a_whole_digest_is_opened(digest: str) -> None:
"""The digest is joined into a path, so anything else could read any file."""
with pytest.raises(ValueError, match="not an artifact reference"):
_Reporter("fluksio").load_artifact({"digest": digest})
def _gone(pid: int) -> bool:
"""No such process, or one only waiting to be reaped."""
try:
return Path(f"/proc/{pid}/stat").read_text().split()[2] == "Z"
except FileNotFoundError:
return True
def test_stopping_a_service_takes_its_process_group_with_it(tmp_path: Path) -> None:
"""A child of the server left running would keep its port."""
args = argparse.Namespace(
url="wss://engine.example.com/api/v1/workers/attach",
artifact_url="",
token="t",
)
worker = agent.Agent(args)
child = tmp_path / "child"
sent: list[dict] = []
class Socket:
async def send(self, text: str) -> None:
sent.append(json.loads(text))
async def scenario() -> None:
script = f"sleep 300 & echo $! > {child}; wait"
request = {"op": "serve", "call_id": "svc", "argv": ["sh", "-c", script]}
task = asyncio.create_task(worker._serve(Socket(), request))
for _ in range(500):
if child.exists() and child.read_text().strip():
break
await asyncio.sleep(0.01)
worker._cancel("svc")
await task
asyncio.run(scenario())
pid = int(child.read_text())
deadline = time.monotonic() + 5
while not _gone(pid) and time.monotonic() < deadline:
time.sleep(0.05)
assert _gone(pid)
assert not worker.serving
assert sent == [
{
"call_id": "svc",
"ok": False,
"error": {
"type": "ServiceStopped",
"message": "the service was stopped",
"short": "the service was stopped",
"traceback": "",
},
}
]