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.
@@ -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())