iowap-node 2.3.20__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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""node-daemon — SSE-driven reference daemon for IOWAP.
|
|
3
|
+
|
|
4
|
+
A sibling to ``node-cli daemon`` that replaces the polling claim loop with
|
|
5
|
+
an SSE event stream. Instead of asking the scheduler for work every N
|
|
6
|
+
seconds, the daemon subscribes to ``/relay/v2/events/stream`` and reacts
|
|
7
|
+
to ``stage_claimed`` and ``task_created`` events:
|
|
8
|
+
|
|
9
|
+
* ``task_created`` — a new task is pending; the daemon attempts to
|
|
10
|
+
claim a stage for any capability it advertises.
|
|
11
|
+
* ``stage_claimed`` — a stage was claimed (possibly by another node);
|
|
12
|
+
the daemon checks whether the capability matches one of its own and,
|
|
13
|
+
if so, claims + executes it.
|
|
14
|
+
|
|
15
|
+
Backfill (T-c51219ee): ``task_created`` is a one-shot event, so a task
|
|
16
|
+
that became pending before the SSE connection existed — or that lost
|
|
17
|
+
its initial claim race — would stay ``pending`` forever. After every
|
|
18
|
+
successful SSE connect the daemon sweeps all claimable capabilities
|
|
19
|
+
once, and a periodic ticker (``backfill_interval``, default 60s,
|
|
20
|
+
``RELAY_BACKFILL_INTERVAL``) repeats the sweep as a safety net. The
|
|
21
|
+
sweep reuses the ``max_parallel``/``_in_flight`` guard from the event
|
|
22
|
+
handler; claims are idempotent (the server grants them atomically).
|
|
23
|
+
|
|
24
|
+
``node-cli daemon`` remains unchanged; this module is a separate entry
|
|
25
|
+
point (``node-daemon``) that can run side-by-side without affecting the
|
|
26
|
+
existing daemon.
|
|
27
|
+
|
|
28
|
+
Architecture (threads)::
|
|
29
|
+
|
|
30
|
+
Thread 1: Heartbeat — identical to node-cli daemon (every 30s)
|
|
31
|
+
Thread 2: SSE client — event stream with automatic reconnect
|
|
32
|
+
Thread 3: Claim/Execute/Complete — triggered from SSE events
|
|
33
|
+
Thread 4: Backfill ticker — periodic claim sweep (T-c51219ee)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import asyncio
|
|
40
|
+
import json
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import signal
|
|
45
|
+
import sys
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
import httpx
|
|
52
|
+
|
|
53
|
+
from nodes.common import file_serve
|
|
54
|
+
from nodes.common.handler_runner import run_handler
|
|
55
|
+
from nodes.common.node_config import (
|
|
56
|
+
current_profile_name,
|
|
57
|
+
ensure_node_cli_stamp,
|
|
58
|
+
invalidate_active_cache,
|
|
59
|
+
load_active_profile,
|
|
60
|
+
)
|
|
61
|
+
from nodes.common.node_utils import (
|
|
62
|
+
BASE_DIR,
|
|
63
|
+
STATUS_PATH,
|
|
64
|
+
TOKEN_PATH,
|
|
65
|
+
load_meta,
|
|
66
|
+
post_result_rescue_note,
|
|
67
|
+
write_json_atomic,
|
|
68
|
+
)
|
|
69
|
+
from nodes.common.relay_client import RelayClient, _effective_config, _setup_logging
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Paths (mirrors node_cli.py)
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
PID_PATH = BASE_DIR / "node-daemon.pid"
|
|
76
|
+
LOG_PATH = BASE_DIR / "node-daemon.log"
|
|
77
|
+
|
|
78
|
+
# T-137b-Guard: the polling daemon's PID file. Both daemons must never run
|
|
79
|
+
# at the same time as the same node (token fight + task race).
|
|
80
|
+
_POLLING_PID_PATH = BASE_DIR / "node-cli.pid"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_other_daemon(
|
|
84
|
+
base_dir: Path | None = None,
|
|
85
|
+
other_pid_path: Path | None = None,
|
|
86
|
+
own_name: str = "node-daemon",
|
|
87
|
+
other_name: str = "node-cli daemon",
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""Return an error message if the polling daemon is running, else None."""
|
|
90
|
+
from nodes.common import node_utils as _nu
|
|
91
|
+
|
|
92
|
+
pid_file = other_pid_path or (base_dir or BASE_DIR) / "node-cli.pid"
|
|
93
|
+
pid = _nu.read_pid(pid_file)
|
|
94
|
+
if pid is not None and _nu.pid_running(pid):
|
|
95
|
+
return (
|
|
96
|
+
f"{own_name} refuses to start: {other_name} is already running "
|
|
97
|
+
f"(pid {pid}, {pid_file}). Stop it first — running both as the "
|
|
98
|
+
f"same node causes token fights and duplicate claims (T-137b)."
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Logging
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
log = logging.getLogger("node-daemon")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _utcnow_str() -> str:
|
|
110
|
+
return datetime.now(timezone.utc).isoformat()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Long-Run lease budget on the relay (T-154): an accepted stage stays
|
|
114
|
+
# alive for this long as long as progress notes keep resetting the TTL.
|
|
115
|
+
# A long_run handler process gets this budget AS A FLOOR (T-213): a
|
|
116
|
+
# capability profile may extend the budget via its ``timeout`` field —
|
|
117
|
+
# never shorten it below the lease budget.
|
|
118
|
+
_LONGRUN_HANDLER_TIMEOUT = 2 * 3600 # 2h
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _handler_timeout(cap: dict[str, Any]) -> int:
|
|
122
|
+
"""Return the subprocess timeout for a capability's handler.
|
|
123
|
+
|
|
124
|
+
A ``long_run`` capability (archive/extract) must not be killed by the
|
|
125
|
+
short per-stage timeout (default 300s) — the relay's Long-Run lease
|
|
126
|
+
keeps the stage ``accepted`` for as long as progress notes keep
|
|
127
|
+
arriving, so the handler gets at least the 2h lease budget (T-163).
|
|
128
|
+
|
|
129
|
+
T-213: the capability profile's own ``timeout`` field RAISES that
|
|
130
|
+
floor when it is larger (e.g. ``video.gen.ltx`` declares 10800s for
|
|
131
|
+
multi-hour renders). The configured value can extend, never shorten,
|
|
132
|
+
the lease budget — a too-short profile value cannot revive the
|
|
133
|
+
per-stage kill the lease mechanism was built to prevent.
|
|
134
|
+
"""
|
|
135
|
+
if cap.get("long_run"):
|
|
136
|
+
try:
|
|
137
|
+
cap_timeout = int(cap.get("timeout") or 0)
|
|
138
|
+
except (TypeError, ValueError):
|
|
139
|
+
cap_timeout = 0
|
|
140
|
+
if cap_timeout > _LONGRUN_HANDLER_TIMEOUT:
|
|
141
|
+
return cap_timeout
|
|
142
|
+
return _LONGRUN_HANDLER_TIMEOUT
|
|
143
|
+
return int(cap.get("timeout", 300))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Event types the daemon is interested in.
|
|
147
|
+
_SUBSCRIBED_TYPES = "stage_claimed,task_created"
|
|
148
|
+
|
|
149
|
+
# Backfill (T-c51219ee): the daemon is purely SSE-event-driven and
|
|
150
|
+
# ``task_created`` is a one-shot event — a task that became pending
|
|
151
|
+
# before the SSE connection existed (daemon start, reload, network
|
|
152
|
+
# blip) or that lost its initial claim race never triggers another
|
|
153
|
+
# event and stays ``pending`` forever. Two safety nets fix this:
|
|
154
|
+
#
|
|
155
|
+
# * a claim sweep over every claimable capability right after a
|
|
156
|
+
# successful SSE connect (``_consume_stream``), and
|
|
157
|
+
# * a periodic ticker that repeats the sweep every
|
|
158
|
+
# ``backfill_interval`` seconds while connected.
|
|
159
|
+
#
|
|
160
|
+
# A claim attempt is idempotent and cheap (the server atomically
|
|
161
|
+
# grants the claim to the first requester; a null claim is a 204), so
|
|
162
|
+
# an extra sweep cannot hurt. Default: 60s (override via
|
|
163
|
+
# RELAY_BACKFILL_INTERVAL env or ``backfill_interval`` in
|
|
164
|
+
# relay_config.json; 0 disables the ticker).
|
|
165
|
+
_DEFAULT_BACKFILL_INTERVAL = 60
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _cfg_int(cfg: dict[str, Any], key: str, default: int) -> int:
|
|
169
|
+
"""Read an integer from cfg, falling back to ``default`` on garbage."""
|
|
170
|
+
raw = cfg.get(key, default)
|
|
171
|
+
try:
|
|
172
|
+
return int(raw)
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
log.warning("ignoring invalid %s=%r — using %d", key, raw, default)
|
|
175
|
+
return default
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _backfill_interval(cfg: dict[str, Any]) -> int:
|
|
179
|
+
"""Effective backfill sweep interval in seconds (0 = ticker off)."""
|
|
180
|
+
env = os.environ.get("RELAY_BACKFILL_INTERVAL")
|
|
181
|
+
if env is not None:
|
|
182
|
+
try:
|
|
183
|
+
return int(env)
|
|
184
|
+
except ValueError:
|
|
185
|
+
log.warning(
|
|
186
|
+
"ignoring invalid RELAY_BACKFILL_INTERVAL=%r", env
|
|
187
|
+
)
|
|
188
|
+
return _cfg_int(cfg, "backfill_interval", _DEFAULT_BACKFILL_INTERVAL)
|
|
189
|
+
|
|
190
|
+
# Reconnect delay after a broken SSE connection.
|
|
191
|
+
_RECONNECT_DELAY = 5.0
|
|
192
|
+
|
|
193
|
+
# T-186: SSE timeouts — timeout=None disabled BOTH connect and read
|
|
194
|
+
# timeouts: with the server down, each reconnect attempt hung ~2min in
|
|
195
|
+
# SYN retransmits; after a hard server kill (no EOF) the connection
|
|
196
|
+
# became a TCP zombie and never reconnected. Read timeout is 3× the
|
|
197
|
+
# server's SSE ping interval (T-207 iowap-server, default 20s).
|
|
198
|
+
_SSE_CONNECT_TIMEOUT = 5.0
|
|
199
|
+
_SSE_READ_TIMEOUT = 60.0
|
|
200
|
+
_SSE_STREAM_TIMEOUT = httpx.Timeout(
|
|
201
|
+
connect=_SSE_CONNECT_TIMEOUT,
|
|
202
|
+
write=5.0,
|
|
203
|
+
read=_SSE_READ_TIMEOUT,
|
|
204
|
+
pool=5.0,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# T-179 Task 5: the relay's 404 detail when a stage is already completed
|
|
208
|
+
# (iowap-server api/v2/scheduler.py, complete endpoint).
|
|
209
|
+
_ALREADY_COMPLETED_DETAIL = "not claimed by this node, or not in claimed status"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _is_already_completed(exc: BaseException) -> bool:
|
|
213
|
+
"""Return True if ``exc`` is the relay's 404 'already completed' answer.
|
|
214
|
+
|
|
215
|
+
Used by the ``complete_by_script`` opt-in (T-179 Task 5): when a
|
|
216
|
+
handler script completes the stage itself, the daemon's fallback
|
|
217
|
+
``complete`` call is rejected with this exact 404 detail — that is a
|
|
218
|
+
success, not a failure.
|
|
219
|
+
"""
|
|
220
|
+
response = getattr(exc, "response", None)
|
|
221
|
+
if response is None or getattr(response, "status_code", None) != 404:
|
|
222
|
+
return False
|
|
223
|
+
try:
|
|
224
|
+
detail = response.json().get("detail", "")
|
|
225
|
+
except Exception: # noqa: BLE001 — non-JSON body can never match
|
|
226
|
+
return False
|
|
227
|
+
return _ALREADY_COMPLETED_DETAIL in detail
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class SseDaemon:
|
|
231
|
+
"""SSE-driven daemon: heartbeat thread + SSE event loop + execution.
|
|
232
|
+
|
|
233
|
+
Mirrors ``node_cli.Daemon`` for the heartbeat and stage-execution
|
|
234
|
+
logic but replaces the polling claim loop with an SSE client that
|
|
235
|
+
reacts to ``stage_claimed`` / ``task_created`` events.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self, client: RelayClient, cfg: dict[str, Any]) -> None:
|
|
239
|
+
self.client = client
|
|
240
|
+
self.cfg = cfg
|
|
241
|
+
self._stop_event = threading.Event()
|
|
242
|
+
self._sse_thread: threading.Thread | None = None
|
|
243
|
+
self._hb_thread: threading.Thread | None = None
|
|
244
|
+
# T-185: quiescence window. SET = normal operation, CLEAR = a
|
|
245
|
+
# credential-maintenance window is open: the SSE loop drops its
|
|
246
|
+
# active connection (the server sends no keepalive —
|
|
247
|
+
# core/events.py) and holds reconnects; the heartbeat thread is
|
|
248
|
+
# the window owner and pauses itself while rotating.
|
|
249
|
+
self._quiesce = threading.Event()
|
|
250
|
+
self._quiesce.set()
|
|
251
|
+
# T-177: last server probe result (health/ready/metrics), written by
|
|
252
|
+
# the probe thread and merged into the status file by _write_status.
|
|
253
|
+
self.server_probe: dict[str, Any] = {"ok": False, "error": "probe pending"}
|
|
254
|
+
self._probe_thread: threading.Thread | None = None
|
|
255
|
+
self._in_flight: dict[str, int] = {}
|
|
256
|
+
self._lock = threading.Lock()
|
|
257
|
+
self._started_at = datetime.now(timezone.utc)
|
|
258
|
+
self.tasks_completed = 0
|
|
259
|
+
self.tasks_failed = 0
|
|
260
|
+
self.last_heartbeat_status = "unknown"
|
|
261
|
+
# T-060 mirror: per-task failure counter so the daemon stops
|
|
262
|
+
# reclaiming stages for a task whose handler keeps failing.
|
|
263
|
+
self._failed_tasks: dict[str, int] = {}
|
|
264
|
+
# Backfill ticker (T-c51219ee): periodic claim sweep while the
|
|
265
|
+
# SSE stream is connected. Started once by the first successful
|
|
266
|
+
# SSE connect; the sweep itself must NOT fire while the ticker
|
|
267
|
+
# thread races a live stream on another thread — therefore the
|
|
268
|
+
# ticker is armed only from the SSE thread.
|
|
269
|
+
self._backfill_thread: threading.Thread | None = None
|
|
270
|
+
self._backfill_armed = threading.Event()
|
|
271
|
+
|
|
272
|
+
# -- signal handling ---------------------------------------------------
|
|
273
|
+
|
|
274
|
+
def _install_signal_handlers(self) -> None:
|
|
275
|
+
signal.signal(signal.SIGTERM, self._on_term)
|
|
276
|
+
signal.signal(signal.SIGINT, self._on_term)
|
|
277
|
+
signal.signal(signal.SIGHUP, self._on_hup)
|
|
278
|
+
|
|
279
|
+
def _on_term(self, *_: Any) -> None:
|
|
280
|
+
log.info("received shutdown signal, stopping node-daemon …")
|
|
281
|
+
self._stop_event.set()
|
|
282
|
+
|
|
283
|
+
def _on_hup(self, *_: Any) -> None:
|
|
284
|
+
log.info("received SIGHUP, invalidating capability cache")
|
|
285
|
+
invalidate_active_cache()
|
|
286
|
+
|
|
287
|
+
# -- status file -------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def _write_status(self, error: str | None = None) -> None:
|
|
290
|
+
caps = load_active_profile()
|
|
291
|
+
# T-108: loop detection — when the daemon is stuck in an auth
|
|
292
|
+
# error loop (backoff > 0 + 401/403), we enrich the error string
|
|
293
|
+
# and mark the status as degraded. The core logic lives in the
|
|
294
|
+
# shared RelayClient (applies to both daemons).
|
|
295
|
+
backoff = self.client._current_backoff()
|
|
296
|
+
auth_loop = backoff > 0 and "401" in (error or "")
|
|
297
|
+
if auth_loop:
|
|
298
|
+
error = (error or "") + " | AUTH-LOOP: token invalid — check the token file or restart the daemon"
|
|
299
|
+
status = {
|
|
300
|
+
"pid": os.getpid(),
|
|
301
|
+
"node_id": self.client.meta.get("node_id"),
|
|
302
|
+
"daemon": "node-daemon",
|
|
303
|
+
"started_at": self._started_at.isoformat(),
|
|
304
|
+
"last_heartbeat": _utcnow_str(),
|
|
305
|
+
"heartbeat_status": self.last_heartbeat_status,
|
|
306
|
+
"active_profile": current_profile_name(),
|
|
307
|
+
"token_present": bool(self.client.token),
|
|
308
|
+
"capabilities": [
|
|
309
|
+
{"name": c["name"], "claimable": c.get("claimable", False)} for c in caps
|
|
310
|
+
],
|
|
311
|
+
"in_flight": dict(self._in_flight),
|
|
312
|
+
"tasks_completed": self.tasks_completed,
|
|
313
|
+
"tasks_failed": self.tasks_failed,
|
|
314
|
+
"failed_tasks": dict(self._failed_tasks),
|
|
315
|
+
"error": error,
|
|
316
|
+
"auth_loop": auth_loop,
|
|
317
|
+
"auth_backoff_seconds": backoff,
|
|
318
|
+
"server": self.server_probe,
|
|
319
|
+
}
|
|
320
|
+
try:
|
|
321
|
+
write_json_atomic(STATUS_PATH, status)
|
|
322
|
+
except OSError as exc:
|
|
323
|
+
log.warning("could not write status file: %s", exc)
|
|
324
|
+
|
|
325
|
+
# -- heartbeat thread --------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def _heartbeat_loop(self) -> None:
|
|
328
|
+
"""Heartbeat loop, identical to node-cli daemon."""
|
|
329
|
+
interval = self.cfg["heartbeat_interval"]
|
|
330
|
+
while not self._stop_event.is_set():
|
|
331
|
+
error: str | None = None
|
|
332
|
+
try:
|
|
333
|
+
# T-182/T-183: fixed-interval credential maintenance with
|
|
334
|
+
# claims paused. T-185: the rotation runs inside a
|
|
335
|
+
# quiescence window (SSE dropped + reconnects held) and
|
|
336
|
+
# ONLY when a rotation is actually due — an idle window on
|
|
337
|
+
# every 8 s tick would drop SSE for nothing.
|
|
338
|
+
if self.client.maintenance_due():
|
|
339
|
+
self._run_maintenance_window()
|
|
340
|
+
caps = load_active_profile()
|
|
341
|
+
with self._lock:
|
|
342
|
+
inflight = dict(self._in_flight)
|
|
343
|
+
hb = self.client.heartbeat(caps, inflight)
|
|
344
|
+
self.last_heartbeat_status = hb.get("status", "ok")
|
|
345
|
+
log.info("heartbeat %s", self.last_heartbeat_status)
|
|
346
|
+
except httpx.HTTPStatusError as exc:
|
|
347
|
+
error = f"http {exc.response.status_code}"
|
|
348
|
+
log.error("heartbeat http error %s", error)
|
|
349
|
+
except Exception as exc: # noqa: BLE001 — daemon must survive
|
|
350
|
+
error = str(exc)
|
|
351
|
+
log.error("heartbeat error: %s", exc)
|
|
352
|
+
self._write_status(error=error)
|
|
353
|
+
# T-108: back off after repeated auth failures (handled
|
|
354
|
+
# centrally in the RelayClient). SSE reconnect stays separate
|
|
355
|
+
# via _RECONNECT_DELAY because it covers different error
|
|
356
|
+
# causes.
|
|
357
|
+
sleep_interval = interval + self.client._current_backoff()
|
|
358
|
+
for _ in range(max(1, int(sleep_interval))):
|
|
359
|
+
if self._stop_event.is_set():
|
|
360
|
+
return
|
|
361
|
+
time.sleep(1)
|
|
362
|
+
|
|
363
|
+
def _run_maintenance_window(self) -> None:
|
|
364
|
+
"""Open the quiescence window around one maintenance tick (T-185).
|
|
365
|
+
|
|
366
|
+
Window owner = the heartbeat thread. While the window is open the
|
|
367
|
+
SSE loop cancels its active stream and holds reconnects, so the
|
|
368
|
+
rotation never runs against a live connection presenting the old
|
|
369
|
+
token. The window ALWAYS re-opens (finally), even on failure —
|
|
370
|
+
a wedged daemon must not outlive one bad tick.
|
|
371
|
+
"""
|
|
372
|
+
log.info("maintenance window open — dropping SSE, pausing rotation")
|
|
373
|
+
self._quiesce.clear()
|
|
374
|
+
try:
|
|
375
|
+
self.client.run_credential_maintenance()
|
|
376
|
+
except Exception as exc: # noqa: BLE001 — the window must always close
|
|
377
|
+
log.error("maintenance window: rotation failed: %s", exc)
|
|
378
|
+
finally:
|
|
379
|
+
self._quiesce.set()
|
|
380
|
+
log.info("maintenance window closed — connections rebuilt with fresh token")
|
|
381
|
+
|
|
382
|
+
def _start_heartbeat_thread(self) -> None:
|
|
383
|
+
self._hb_thread = threading.Thread(
|
|
384
|
+
target=self._heartbeat_loop, daemon=True, name="heartbeat"
|
|
385
|
+
)
|
|
386
|
+
self._hb_thread.start()
|
|
387
|
+
|
|
388
|
+
# -- T-177: server probe thread -------------------------------------------
|
|
389
|
+
|
|
390
|
+
def _probe_loop(self) -> None:
|
|
391
|
+
"""Probe relay /health + /ready + /metrics on a fixed cadence.
|
|
392
|
+
|
|
393
|
+
Mirror of the node-cli daemon probe loop: own daemon thread so a
|
|
394
|
+
hanging HTTP call can never stall heartbeats or the SSE loop.
|
|
395
|
+
Failures are recorded (never raised): the next tick retries.
|
|
396
|
+
30 s cadence → worst-case server-outage visibility latency is
|
|
397
|
+
probe + telemetry interval (~90 s end-to-end).
|
|
398
|
+
"""
|
|
399
|
+
interval = 30
|
|
400
|
+
while not self._stop_event.is_set():
|
|
401
|
+
try:
|
|
402
|
+
self.server_probe = self.client.probe_server()
|
|
403
|
+
except Exception as exc: # noqa: BLE001 — probe must survive
|
|
404
|
+
log.warning("server probe failed: %s", exc)
|
|
405
|
+
self.server_probe = {"ok": False, "error": str(exc)}
|
|
406
|
+
for _ in range(interval):
|
|
407
|
+
if self._stop_event.is_set():
|
|
408
|
+
return
|
|
409
|
+
time.sleep(1)
|
|
410
|
+
|
|
411
|
+
def _start_probe_thread(self) -> None:
|
|
412
|
+
self._probe_thread = threading.Thread(
|
|
413
|
+
target=self._probe_loop, daemon=True, name="server-probe"
|
|
414
|
+
)
|
|
415
|
+
self._probe_thread.start()
|
|
416
|
+
|
|
417
|
+
# -- SSE client --------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def _stream_url(self) -> str:
|
|
420
|
+
node_id = self.client.meta.get("node_id", "")
|
|
421
|
+
return (
|
|
422
|
+
f"{self.client.base_url}/relay/v2/events/stream"
|
|
423
|
+
f"?node={node_id}&types={_SUBSCRIBED_TYPES}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def _sse_loop(self) -> None:
|
|
427
|
+
"""Run the asyncio SSE client until ``_stop_event`` is set.
|
|
428
|
+
|
|
429
|
+
The SSE client is async (httpx streaming), but the daemon is
|
|
430
|
+
synchronous/threaded, so we run it inside ``asyncio.run`` in this
|
|
431
|
+
dedicated thread.
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
asyncio.run(self._sse_loop_async())
|
|
435
|
+
except Exception as exc: # noqa: BLE001 — daemon must survive
|
|
436
|
+
log.error("SSE loop crashed: %s", exc)
|
|
437
|
+
|
|
438
|
+
async def _sse_loop_async(self) -> None:
|
|
439
|
+
url = self._stream_url()
|
|
440
|
+
async with httpx.AsyncClient() as http:
|
|
441
|
+
while not self._stop_event.is_set():
|
|
442
|
+
# T-185: while a maintenance window is open, hold the loop —
|
|
443
|
+
# no connection attempt presents a token about to rotate.
|
|
444
|
+
await self._wait_until_normal()
|
|
445
|
+
if self._stop_event.is_set():
|
|
446
|
+
return
|
|
447
|
+
# T-184: rebuild the auth header on EVERY attempt. A token
|
|
448
|
+
# rotation (credential maintenance) must not leave the SSE
|
|
449
|
+
# loop presenting a stale snapshot forever — that caused a
|
|
450
|
+
# permanent 401 reconnect cycle (2026-09-13).
|
|
451
|
+
headers = {"Authorization": f"Bearer {self.client.token}"}
|
|
452
|
+
consume = asyncio.ensure_future(
|
|
453
|
+
self._consume_stream(http, url, headers)
|
|
454
|
+
)
|
|
455
|
+
watch = asyncio.ensure_future(self._watch_quiesce())
|
|
456
|
+
try:
|
|
457
|
+
await asyncio.wait(
|
|
458
|
+
{consume, watch}, return_when=asyncio.FIRST_COMPLETED
|
|
459
|
+
)
|
|
460
|
+
finally:
|
|
461
|
+
# T-185: if the window opened (or we are stopping), the
|
|
462
|
+
# active stream is cancelled — the server sends no
|
|
463
|
+
# keepalive, so quiescence must drop it actively.
|
|
464
|
+
for task in (consume, watch):
|
|
465
|
+
if not task.done():
|
|
466
|
+
task.cancel()
|
|
467
|
+
# Await both so nothing is left dangling; swallow every
|
|
468
|
+
# outcome — a consume error is handled below, a cancel
|
|
469
|
+
# is expected here.
|
|
470
|
+
for task in (consume, watch):
|
|
471
|
+
try:
|
|
472
|
+
await task
|
|
473
|
+
except BaseException as task_exc: # noqa: BLE001 — cleanup only
|
|
474
|
+
log.debug("SSE task cleanup: %r", task_exc)
|
|
475
|
+
exc = (
|
|
476
|
+
consume.exception()
|
|
477
|
+
if consume.done() and not consume.cancelled()
|
|
478
|
+
else None
|
|
479
|
+
)
|
|
480
|
+
if exc is not None:
|
|
481
|
+
log.warning("SSE connection error: %s", exc)
|
|
482
|
+
if (
|
|
483
|
+
watch.done()
|
|
484
|
+
and not watch.cancelled()
|
|
485
|
+
and not watch.exception()
|
|
486
|
+
):
|
|
487
|
+
log.info("quiescence window: SSE connection dropped")
|
|
488
|
+
if self._stop_event.is_set():
|
|
489
|
+
return
|
|
490
|
+
if not self._quiesce.is_set():
|
|
491
|
+
# Window still open: hold (no reconnect attempts).
|
|
492
|
+
continue
|
|
493
|
+
# Wait before reconnecting, but stay responsive to stop.
|
|
494
|
+
log.info("SSE reconnecting in %.0fs …", _RECONNECT_DELAY)
|
|
495
|
+
for _ in range(int(_RECONNECT_DELAY * 10)):
|
|
496
|
+
if self._stop_event.is_set():
|
|
497
|
+
return
|
|
498
|
+
await asyncio.sleep(0.1)
|
|
499
|
+
|
|
500
|
+
async def _wait_until_normal(self) -> None:
|
|
501
|
+
"""Block until the quiescence window closes (T-185)."""
|
|
502
|
+
while not self._quiesce.is_set() and not self._stop_event.is_set():
|
|
503
|
+
await asyncio.sleep(0.05)
|
|
504
|
+
|
|
505
|
+
async def _watch_quiesce(self) -> None:
|
|
506
|
+
"""Return when a maintenance window opens (T-185)."""
|
|
507
|
+
while self._quiesce.is_set() and not self._stop_event.is_set():
|
|
508
|
+
await asyncio.sleep(0.05)
|
|
509
|
+
|
|
510
|
+
async def _consume_stream(
|
|
511
|
+
self,
|
|
512
|
+
http: httpx.AsyncClient,
|
|
513
|
+
url: str,
|
|
514
|
+
headers: dict[str, str],
|
|
515
|
+
) -> None:
|
|
516
|
+
async with http.stream(
|
|
517
|
+
"GET", url, headers=headers, timeout=_SSE_STREAM_TIMEOUT
|
|
518
|
+
) as resp:
|
|
519
|
+
resp.raise_for_status()
|
|
520
|
+
log.info("SSE connected to %s", url)
|
|
521
|
+
# Backfill (T-c51219ee): every successful SSE connect first
|
|
522
|
+
# sweeps already-pending work — one-shot ``task_created``
|
|
523
|
+
# events that fired before this connection exist can never
|
|
524
|
+
# arrive anymore. Best-effort: a sweep error must not kill
|
|
525
|
+
# the stream. Also arms the periodic claim ticker exactly
|
|
526
|
+
# once (this runs in the SSE thread).
|
|
527
|
+
try:
|
|
528
|
+
self._on_sse_connected()
|
|
529
|
+
except Exception as exc: # noqa: BLE001 — stream must survive
|
|
530
|
+
log.warning("SSE connect backfill failed: %s", exc)
|
|
531
|
+
buffer: list[str] = []
|
|
532
|
+
async for line in resp.aiter_lines():
|
|
533
|
+
if self._stop_event.is_set():
|
|
534
|
+
return
|
|
535
|
+
if line == "":
|
|
536
|
+
event = self._parse_sse(buffer)
|
|
537
|
+
buffer = []
|
|
538
|
+
if event is not None:
|
|
539
|
+
self._on_event(event)
|
|
540
|
+
else:
|
|
541
|
+
buffer.append(line)
|
|
542
|
+
|
|
543
|
+
@staticmethod
|
|
544
|
+
def _parse_sse(lines: list[str]) -> dict[str, Any] | None:
|
|
545
|
+
"""Parse a single SSE message (one blank-line-delimited block)."""
|
|
546
|
+
event_type = "message"
|
|
547
|
+
data: str | None = None
|
|
548
|
+
for line in lines:
|
|
549
|
+
if line.startswith("event: "):
|
|
550
|
+
event_type = line[7:].strip()
|
|
551
|
+
elif line.startswith("data: "):
|
|
552
|
+
data = line[6:]
|
|
553
|
+
if data is None:
|
|
554
|
+
return None
|
|
555
|
+
try:
|
|
556
|
+
payload = json.loads(data)
|
|
557
|
+
except json.JSONDecodeError:
|
|
558
|
+
payload = {"raw": data}
|
|
559
|
+
return {"type": event_type, "data": payload}
|
|
560
|
+
|
|
561
|
+
# -- event handling ----------------------------------------------------
|
|
562
|
+
|
|
563
|
+
def _on_event(self, event: dict[str, Any]) -> None:
|
|
564
|
+
"""Dispatch an SSE event to the appropriate handler.
|
|
565
|
+
|
|
566
|
+
Runs synchronously in the SSE thread. Stage execution may block,
|
|
567
|
+
which pauses event consumption for its duration — this mirrors
|
|
568
|
+
the sequential contract of ``node-cli daemon`` (see spec §15.2)
|
|
569
|
+
and is intentional: max_parallel is enforced via ``_in_flight``.
|
|
570
|
+
"""
|
|
571
|
+
event_type = event.get("type")
|
|
572
|
+
payload = event.get("data") or {}
|
|
573
|
+
if event_type == "stage_claimed":
|
|
574
|
+
self._handle_stage_claimed(payload)
|
|
575
|
+
elif event_type == "task_created":
|
|
576
|
+
self._handle_task_created(payload)
|
|
577
|
+
# Other event types are ignored (the stream is already filtered
|
|
578
|
+
# server-side, but be defensive).
|
|
579
|
+
|
|
580
|
+
def _handle_stage_claimed(self, payload: dict[str, Any]) -> None:
|
|
581
|
+
"""A stage was claimed somewhere in the cluster.
|
|
582
|
+
|
|
583
|
+
If the capability matches one this node advertises, attempt to
|
|
584
|
+
claim (and execute) it. The server atomically grants the claim
|
|
585
|
+
to the first requester, so a race with another node is safe —
|
|
586
|
+
the claim simply returns nothing.
|
|
587
|
+
"""
|
|
588
|
+
capability = payload.get("capability")
|
|
589
|
+
if not capability:
|
|
590
|
+
return
|
|
591
|
+
if not self._advertises_capability(capability):
|
|
592
|
+
return
|
|
593
|
+
self._try_claim_and_run(capability)
|
|
594
|
+
|
|
595
|
+
def _handle_task_created(self, payload: dict[str, Any]) -> None:
|
|
596
|
+
"""A new task was created. Try to claim a stage for each
|
|
597
|
+
claimable capability this node advertises."""
|
|
598
|
+
self._backfill_sweep()
|
|
599
|
+
|
|
600
|
+
# -- backfill (T-c51219ee) ----------------------------------------------
|
|
601
|
+
|
|
602
|
+
def _on_sse_connected(self) -> None:
|
|
603
|
+
"""First reaction after a successful SSE connect.
|
|
604
|
+
|
|
605
|
+
Runs in the SSE thread (called from ``_consume_stream`` before
|
|
606
|
+
any event is consumed). Two jobs:
|
|
607
|
+
|
|
608
|
+
1. Immediately sweep for already-pending work — a task created
|
|
609
|
+
before this connection will never re-emit its one-shot
|
|
610
|
+
``task_created`` event.
|
|
611
|
+
2. Arm the periodic backfill ticker (exactly once per daemon
|
|
612
|
+
lifetime); the ticker repeats the sweep so tasks that became
|
|
613
|
+
pending mid-run without an event (claim-race loss, event
|
|
614
|
+
hole) are still picked up.
|
|
615
|
+
"""
|
|
616
|
+
self._backfill_sweep()
|
|
617
|
+
self._arm_backfill_ticker()
|
|
618
|
+
|
|
619
|
+
def _backfill_sweep(self) -> None:
|
|
620
|
+
"""One claim attempt per claimable capability.
|
|
621
|
+
|
|
622
|
+
Same contract as the ``task_created`` handler: respects
|
|
623
|
+
``max_parallel`` via the ``_in_flight`` counters (stage
|
|
624
|
+
execution is sequential in the SSE thread — never claim more
|
|
625
|
+
than the capability can run) and lets the server decide
|
|
626
|
+
atomically whether a stage exists (a null claim is a cheap
|
|
627
|
+
204).
|
|
628
|
+
"""
|
|
629
|
+
caps = load_active_profile()
|
|
630
|
+
for cap in caps:
|
|
631
|
+
if not cap.get("claimable", False):
|
|
632
|
+
continue
|
|
633
|
+
name = cap["name"]
|
|
634
|
+
with self._lock:
|
|
635
|
+
inflight = self._in_flight.get(name, 0)
|
|
636
|
+
if inflight >= int(cap.get("max_parallel", 1)):
|
|
637
|
+
continue
|
|
638
|
+
self._try_claim_and_run(name)
|
|
639
|
+
|
|
640
|
+
def _arm_backfill_ticker(self) -> None:
|
|
641
|
+
"""Start the periodic backfill ticker thread (once)."""
|
|
642
|
+
if self._backfill_armed.is_set():
|
|
643
|
+
return
|
|
644
|
+
self._backfill_armed.set()
|
|
645
|
+
interval = _backfill_interval(self.cfg)
|
|
646
|
+
if interval <= 0:
|
|
647
|
+
log.info("backfill ticker disabled (backfill_interval=%d)", interval)
|
|
648
|
+
return
|
|
649
|
+
self._backfill_thread = threading.Thread(
|
|
650
|
+
target=self._backfill_ticker_loop,
|
|
651
|
+
daemon=True,
|
|
652
|
+
name="backfill-ticker",
|
|
653
|
+
)
|
|
654
|
+
self._backfill_thread.start()
|
|
655
|
+
log.info(
|
|
656
|
+
"backfill ticker armed (every %ds) — catches tasks that became "
|
|
657
|
+
"pending without a task_created event",
|
|
658
|
+
interval,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _backfill_ticker_loop(self) -> None:
|
|
662
|
+
"""Repeat the claim sweep every ``backfill_interval`` seconds.
|
|
663
|
+
|
|
664
|
+
Pure safety net for the live-connection case: while the SSE
|
|
665
|
+
stream is up, ``task_created`` events normally trigger claims;
|
|
666
|
+
the ticker only rescues tasks that were missed (claim race,
|
|
667
|
+
event hole, dropped event). Stops with the daemon.
|
|
668
|
+
"""
|
|
669
|
+
interval = _backfill_interval(self.cfg)
|
|
670
|
+
while not self._stop_event.is_set():
|
|
671
|
+
# Sleep in small slices so shutdown stays responsive.
|
|
672
|
+
for _ in range(max(1, interval)):
|
|
673
|
+
if self._stop_event.is_set():
|
|
674
|
+
return
|
|
675
|
+
time.sleep(1)
|
|
676
|
+
if self._stop_event.is_set():
|
|
677
|
+
return
|
|
678
|
+
try:
|
|
679
|
+
self._backfill_sweep()
|
|
680
|
+
except Exception as exc: # noqa: BLE001 — ticker must survive
|
|
681
|
+
log.warning("backfill sweep failed: %s", exc)
|
|
682
|
+
|
|
683
|
+
def _try_claim_and_run(self, capability: str) -> None:
|
|
684
|
+
max_retries = int(self.cfg.get("max_retries", 2))
|
|
685
|
+
try:
|
|
686
|
+
stage = self.client.claim(capability)
|
|
687
|
+
except Exception as exc: # noqa: BLE001 — never crash the SSE loop
|
|
688
|
+
log.error("claim %s failed: %s", capability, exc)
|
|
689
|
+
return
|
|
690
|
+
if stage is None:
|
|
691
|
+
return
|
|
692
|
+
task_id = str(stage.get("task_id") or "")
|
|
693
|
+
with self._lock:
|
|
694
|
+
failures = self._failed_tasks.get(task_id, 0)
|
|
695
|
+
if task_id and failures >= max_retries:
|
|
696
|
+
log.warning(
|
|
697
|
+
"skipping stage %s for task %s — %d failures >= max_retries %d",
|
|
698
|
+
stage.get("stage_id"), task_id, failures, max_retries,
|
|
699
|
+
)
|
|
700
|
+
return
|
|
701
|
+
cap = self._find_capability(capability)
|
|
702
|
+
if cap is None:
|
|
703
|
+
log.warning("claimed stage for unknown capability %s", capability)
|
|
704
|
+
return
|
|
705
|
+
# T-154: if the capability declares long_run, signal the relay
|
|
706
|
+
# immediately so the stage is switched to `accepted` and the
|
|
707
|
+
# 2h lease starts (instead of the 300s claim timeout killing it).
|
|
708
|
+
if task_id and cap.get("long_run"):
|
|
709
|
+
try:
|
|
710
|
+
self.client.add_task_note(
|
|
711
|
+
task_id,
|
|
712
|
+
f"long-running {capability} started",
|
|
713
|
+
kind="longrun",
|
|
714
|
+
)
|
|
715
|
+
except Exception as exc: # noqa: BLE001 — best-effort; never block the claim
|
|
716
|
+
log.warning("longrun note failed for %s: %s", task_id, exc)
|
|
717
|
+
self._run_stage(cap, stage)
|
|
718
|
+
|
|
719
|
+
# -- claim/execute/complete (mirrors node-cli daemon) ------------------
|
|
720
|
+
|
|
721
|
+
def _run_stage(self, cap: dict[str, Any], stage: dict[str, Any]) -> None:
|
|
722
|
+
name = cap["name"]
|
|
723
|
+
stage_id = stage.get("stage_id")
|
|
724
|
+
task_id = stage.get("task_id")
|
|
725
|
+
log.info("claimed %s stage %s (task %s)", name, stage_id, task_id)
|
|
726
|
+
with self._lock:
|
|
727
|
+
self._in_flight[name] = self._in_flight.get(name, 0) + 1
|
|
728
|
+
try:
|
|
729
|
+
context = {
|
|
730
|
+
"RELAY_STAGE_ID": str(stage_id or ""),
|
|
731
|
+
"RELAY_TASK_ID": str(task_id or ""),
|
|
732
|
+
"RELAY_CAPABILITY": name,
|
|
733
|
+
"RELAY_NODE_ID": str(self.client.meta.get("node_id", "")),
|
|
734
|
+
"RELAY_BASE_URL": self.client.base_url,
|
|
735
|
+
"RELAY_TOKEN_FILE": str(TOKEN_PATH),
|
|
736
|
+
}
|
|
737
|
+
result = run_handler(
|
|
738
|
+
cap.get("handler", ""),
|
|
739
|
+
stage,
|
|
740
|
+
context=context,
|
|
741
|
+
# T-154/T-163: a long_run capability must not be killed by
|
|
742
|
+
# the short per-stage timeout (default 300s). The relay's
|
|
743
|
+
# Long-Run lease keeps the stage `accepted` for up to 2h as
|
|
744
|
+
# long as progress notes keep arriving, so the handler
|
|
745
|
+
# process gets the same 2h budget. Without this, a big
|
|
746
|
+
# archive/extract is killed mid-run and the stage is
|
|
747
|
+
# re-claimed to start over.
|
|
748
|
+
timeout=_handler_timeout(cap),
|
|
749
|
+
)
|
|
750
|
+
try:
|
|
751
|
+
self.client.complete(str(task_id), str(stage_id), result)
|
|
752
|
+
with self._lock:
|
|
753
|
+
if "error" in result:
|
|
754
|
+
self.tasks_failed += 1
|
|
755
|
+
if task_id is not None:
|
|
756
|
+
self._failed_tasks[str(task_id)] = (
|
|
757
|
+
self._failed_tasks.get(str(task_id), 0) + 1
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
self.tasks_completed += 1
|
|
761
|
+
log.info("completed stage %s", stage_id)
|
|
762
|
+
except Exception as exc: # noqa: BLE001
|
|
763
|
+
# T-179 task 5: complete_by_script — when the handler
|
|
764
|
+
# script completed the stage itself, the relay answers
|
|
765
|
+
# with the 404 "not claimed" detail. With opt-in that
|
|
766
|
+
# counts as success, not as failure.
|
|
767
|
+
script_complete = bool((cap.get("config") or {}).get(
|
|
768
|
+
"complete_by_script"
|
|
769
|
+
))
|
|
770
|
+
if script_complete and _is_already_completed(exc):
|
|
771
|
+
with self._lock:
|
|
772
|
+
self.tasks_completed += 1
|
|
773
|
+
log.info(
|
|
774
|
+
"stage %s already completed by script — counted as done",
|
|
775
|
+
stage_id,
|
|
776
|
+
)
|
|
777
|
+
return
|
|
778
|
+
# T-001c-c: a 404 means the stage is gone server-side
|
|
779
|
+
# (expired/re-queued/failed while the handler ran). The
|
|
780
|
+
# handler DID work — rescue the result as a task note
|
|
781
|
+
# instead of dropping it. Non-404 failures stay retryable.
|
|
782
|
+
response = getattr(exc, "response", None)
|
|
783
|
+
if (
|
|
784
|
+
getattr(response, "status_code", None) == 404
|
|
785
|
+
and task_id
|
|
786
|
+
):
|
|
787
|
+
saved = post_result_rescue_note(
|
|
788
|
+
self.client,
|
|
789
|
+
str(task_id),
|
|
790
|
+
str(stage_id),
|
|
791
|
+
result,
|
|
792
|
+
reason=str(exc),
|
|
793
|
+
)
|
|
794
|
+
if saved:
|
|
795
|
+
log.info(
|
|
796
|
+
"stage %s complete rejected (404) — result rescued as note",
|
|
797
|
+
stage_id,
|
|
798
|
+
)
|
|
799
|
+
with self._lock:
|
|
800
|
+
self.tasks_failed += 1
|
|
801
|
+
if task_id is not None:
|
|
802
|
+
self._failed_tasks[str(task_id)] = (
|
|
803
|
+
self._failed_tasks.get(str(task_id), 0) + 1
|
|
804
|
+
)
|
|
805
|
+
log.error("failed to report result for stage %s: %s", stage_id, exc)
|
|
806
|
+
except Exception as exc: # noqa: BLE001
|
|
807
|
+
with self._lock:
|
|
808
|
+
self.tasks_failed += 1
|
|
809
|
+
if task_id is not None:
|
|
810
|
+
self._failed_tasks[str(task_id)] = (
|
|
811
|
+
self._failed_tasks.get(str(task_id), 0) + 1
|
|
812
|
+
)
|
|
813
|
+
log.error("stage %s execution failed: %s", stage_id, exc)
|
|
814
|
+
finally:
|
|
815
|
+
with self._lock:
|
|
816
|
+
self._in_flight[name] = max(0, self._in_flight.get(name, 1) - 1)
|
|
817
|
+
|
|
818
|
+
# -- capability helpers ------------------------------------------------
|
|
819
|
+
|
|
820
|
+
def _advertises_capability(self, name: str) -> bool:
|
|
821
|
+
return self._find_capability(name) is not None
|
|
822
|
+
|
|
823
|
+
def _find_capability(self, name: str) -> dict[str, Any] | None:
|
|
824
|
+
for cap in load_active_profile():
|
|
825
|
+
if cap.get("name") == name:
|
|
826
|
+
return cap
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
# -- lifecycle ---------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
def run(self) -> None:
|
|
832
|
+
self._install_signal_handlers()
|
|
833
|
+
log.info(
|
|
834
|
+
"node-daemon starting for node %s (base_url=%s)",
|
|
835
|
+
self.client.meta.get("node_id"),
|
|
836
|
+
self.client.base_url,
|
|
837
|
+
)
|
|
838
|
+
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
839
|
+
# T-226: keep the node_cli_path stamp in the active profile — after
|
|
840
|
+
# a host reboot or a wheel redeploy the running interpreter's cli
|
|
841
|
+
# script is the truth consumers resolve their node-cli from.
|
|
842
|
+
ensure_node_cli_stamp()
|
|
843
|
+
# T-185: refresh the registration secret synchronously BEFORE any
|
|
844
|
+
# connection thread starts — the very first heartbeat/SSE request
|
|
845
|
+
# presents fresh credentials (no startup 401 race). Non-fatal on
|
|
846
|
+
# failure: rs stays due and the next maintenance window retries.
|
|
847
|
+
try:
|
|
848
|
+
self.client.refresh_registration_secret()
|
|
849
|
+
log.info("startup: registration secret refreshed before connections")
|
|
850
|
+
except Exception as exc: # noqa: BLE001 — startup must survive
|
|
851
|
+
log.warning("startup rs refresh failed: %s", exc)
|
|
852
|
+
self._write_status()
|
|
853
|
+
self._start_heartbeat_thread()
|
|
854
|
+
self._start_probe_thread()
|
|
855
|
+
# T-166 (F1): ephemeral file serve inside the daemon — the CLI
|
|
856
|
+
# process dies after the stdout envelope, the serve endpoint must
|
|
857
|
+
# survive. Bind errors (port in use) = WARNING, the node keeps
|
|
858
|
+
# running without serve (hp put bridge then reports the F5 error
|
|
859
|
+
# instead of crashing).
|
|
860
|
+
try:
|
|
861
|
+
file_serve.set_on_exhausted(
|
|
862
|
+
lambda route_path: file_serve.unregister_after_transfer(
|
|
863
|
+
self.client, route_path
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
file_serve.start_serve_thread()
|
|
867
|
+
log.info(
|
|
868
|
+
"ephemeral file serve listening on %s:%d",
|
|
869
|
+
file_serve.serve_host(),
|
|
870
|
+
file_serve.serve_port(),
|
|
871
|
+
)
|
|
872
|
+
except (OSError, RuntimeError) as exc:
|
|
873
|
+
log.warning("ephemeral file serve unavailable: %s", exc)
|
|
874
|
+
self._sse_thread = threading.Thread(
|
|
875
|
+
target=self._sse_loop, daemon=True, name="sse"
|
|
876
|
+
)
|
|
877
|
+
self._sse_thread.start()
|
|
878
|
+
try:
|
|
879
|
+
while not self._stop_event.is_set():
|
|
880
|
+
time.sleep(0.5)
|
|
881
|
+
finally:
|
|
882
|
+
self._stop_event.set()
|
|
883
|
+
if self._sse_thread and self._sse_thread.is_alive():
|
|
884
|
+
self._sse_thread.join(timeout=5)
|
|
885
|
+
if self._hb_thread and self._hb_thread.is_alive():
|
|
886
|
+
self._hb_thread.join(timeout=5)
|
|
887
|
+
# Backfill ticker (T-c51219ee): exits on _stop_event; join it
|
|
888
|
+
# so a mid-sweep claim does not outlive the shutdown log line.
|
|
889
|
+
if self._backfill_thread and self._backfill_thread.is_alive():
|
|
890
|
+
self._backfill_thread.join(timeout=5)
|
|
891
|
+
self._write_status()
|
|
892
|
+
log.info("node-daemon stopped")
|
|
893
|
+
|
|
894
|
+
def stop(self) -> None:
|
|
895
|
+
"""Request a graceful shutdown from outside the process."""
|
|
896
|
+
self._stop_event.set()
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
# ---------------------------------------------------------------------------
|
|
900
|
+
# CLI entry point
|
|
901
|
+
# ---------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
904
|
+
parser = argparse.ArgumentParser(
|
|
905
|
+
prog="node-daemon",
|
|
906
|
+
description="SSE-driven daemon for IOWAP.",
|
|
907
|
+
)
|
|
908
|
+
parser.add_argument(
|
|
909
|
+
"--log-level",
|
|
910
|
+
default=None,
|
|
911
|
+
help="Log level (DEBUG/INFO/WARNING/ERROR). Default: env RELAY_LOG_LEVEL or INFO.",
|
|
912
|
+
)
|
|
913
|
+
parser.add_argument(
|
|
914
|
+
"--foreground",
|
|
915
|
+
action="store_true",
|
|
916
|
+
help="Run in the foreground (default). Kept for parity with node-cli.",
|
|
917
|
+
)
|
|
918
|
+
return parser
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def main(argv: list[str] | None = None) -> int:
|
|
922
|
+
args = build_parser().parse_args(argv)
|
|
923
|
+
_setup_logging(args.log_level)
|
|
924
|
+
try:
|
|
925
|
+
meta = load_meta()
|
|
926
|
+
cfg = _effective_config()
|
|
927
|
+
client = RelayClient(meta, cfg)
|
|
928
|
+
except SystemExit as exc:
|
|
929
|
+
print(exc, file=sys.stderr)
|
|
930
|
+
return 1
|
|
931
|
+
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
932
|
+
# T-137b-Guard: refuse to start while the polling daemon is running.
|
|
933
|
+
conflict = _check_other_daemon(base_dir=BASE_DIR)
|
|
934
|
+
if conflict:
|
|
935
|
+
print(conflict, file=sys.stderr)
|
|
936
|
+
return 1
|
|
937
|
+
PID_PATH.write_text(str(os.getpid()) + "\n", encoding="utf-8")
|
|
938
|
+
try:
|
|
939
|
+
SseDaemon(client, cfg).run()
|
|
940
|
+
finally:
|
|
941
|
+
PID_PATH.unlink(missing_ok=True)
|
|
942
|
+
return 0
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
if __name__ == "__main__":
|
|
946
|
+
sys.exit(main())
|