Three fenced worker Pods claim Hermes Kanban runs through a coordinator that owns every state transition, with per-ordinal HMAC authority, a mediated broker-only SCM path, and durable per-ordinal workspaces. Content is the reviewed head of PR #18 (689bcb6e) with PR 16's and PR 19's contributions removed: they were merged in only to validate co-existence and are not prerequisites, so this branch no longer carries them as ancestors. Only PR 14 and PR 15 remain, because the broker boundary and the cli_lane_* decomposition are load-bearing for two of the fixed P0 boundaries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Versioned HTTP and maintenance loop for the execution-pool coordinator."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from execution_pool_protocol import (
|
|
MAX_WIRE_BYTES,
|
|
PROTOCOL_VERSION,
|
|
BoundedHTTPServer,
|
|
PoolStore,
|
|
ProtocolError,
|
|
canonical_json,
|
|
parse_wire,
|
|
read_key,
|
|
)
|
|
|
|
|
|
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
|
STATE_ROOT = DATA_ROOT / "execution-pool"
|
|
KEY_PATH = Path(
|
|
os.environ.get(
|
|
"HERMES_EXECUTION_POOL_KEY_FILE", "/runtime-access/execution-pool-key"
|
|
)
|
|
)
|
|
PORT = int(os.environ.get("HERMES_EXECUTION_POOL_PORT", "9007"))
|
|
RETENTION_SECONDS = int(
|
|
os.environ.get("HERMES_EXECUTION_POOL_RETENTION_SECONDS", "1209600")
|
|
)
|
|
|
|
|
|
def handler_factory(coordinator: Any) -> type[BaseHTTPRequestHandler]:
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = f"hermes-execution-pool/{PROTOCOL_VERSION}"
|
|
|
|
def _reply(self, status: int, value: dict[str, Any]) -> None:
|
|
body = canonical_json(value)
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
if self.path != "/ready":
|
|
self._reply(404, {"error": "not found"})
|
|
return
|
|
try:
|
|
coordinator.store.available_ordinals()
|
|
self._reply(200, {"ready": True, "version": PROTOCOL_VERSION})
|
|
except (OSError, sqlite3.Error):
|
|
self._reply(503, {"ready": False, "version": PROTOCOL_VERSION})
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length <= 0 or length > MAX_WIRE_BYTES:
|
|
raise ProtocolError("invalid content length")
|
|
envelope = parse_wire(self.rfile.read(length))
|
|
routes = {
|
|
"/v1/poll": coordinator.poll,
|
|
"/v1/heartbeat": coordinator.heartbeat,
|
|
"/v1/result": coordinator.result,
|
|
}
|
|
if self.path not in routes:
|
|
self._reply(404, {"error": "not found"})
|
|
return
|
|
self._reply(200, routes[self.path](envelope))
|
|
except ProtocolError as error:
|
|
self._reply(409, {"error": str(error)})
|
|
except Exception as error:
|
|
print(
|
|
f"pool request failed: {type(error).__name__}: {error}",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
self._reply(503, {"error": "coordinator unavailable"})
|
|
|
|
def log_message(self, _format: str, *_arguments: Any) -> None:
|
|
return
|
|
|
|
return Handler
|
|
|
|
|
|
def run(coordinator_type: type[Any]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--once", action="store_true")
|
|
args = parser.parse_args()
|
|
store = PoolStore(STATE_ROOT / "assignments.db")
|
|
coordinator = coordinator_type(read_key(KEY_PATH), store)
|
|
for operation in (
|
|
coordinator.expire_leases,
|
|
coordinator.recover_results,
|
|
coordinator.reconcile,
|
|
coordinator.dispatch,
|
|
):
|
|
operation()
|
|
if args.once:
|
|
return 0
|
|
server = BoundedHTTPServer(
|
|
("0.0.0.0", PORT), handler_factory(coordinator), max_workers=8
|
|
)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
while True:
|
|
for operation in (
|
|
coordinator.expire_leases,
|
|
coordinator.recover_results,
|
|
coordinator.reconcile,
|
|
coordinator.dispatch,
|
|
lambda: store.garbage_collect(RETENTION_SECONDS),
|
|
):
|
|
try:
|
|
operation()
|
|
except Exception as error:
|
|
print(
|
|
f"pool maintenance deferred: {type(error).__name__}: {error}",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
time.sleep(5)
|