cloudagent-daemon 0.2.1__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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Server-sent events consumer for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
This is the SSE side of Sub-plan 1j (SSE+POST transport replacing the
|
|
4
|
+
WebSocket). The daemon GETs ``/v1/daemon/stream`` with a Bearer token and
|
|
5
|
+
``Last-Event-ID`` set to the highest ``seq`` it has durably processed.
|
|
6
|
+
The server replies with ``text/event-stream``; each ``event:``/``id:``/
|
|
7
|
+
``data:`` block is a server→daemon frame.
|
|
8
|
+
|
|
9
|
+
Reconnect strategy
|
|
10
|
+
------------------
|
|
11
|
+
The HTTP+SSE world is intentionally less precise than WebSocket — proxies
|
|
12
|
+
may close idle streams, NAT may cull connections, etc. We:
|
|
13
|
+
|
|
14
|
+
* Reconnect on any non-auth error with exponential backoff (1→2→4→8→16→30s
|
|
15
|
+
cap), reset to 1s on a successful event.
|
|
16
|
+
* Always re-send ``Last-Event-ID`` on reconnect so the server replays
|
|
17
|
+
unacked frames from its catchup buffer (per SSE spec, this is the
|
|
18
|
+
client's responsibility — the server only sees the header).
|
|
19
|
+
* Treat ``401``/``403`` as terminal (``AuthFailed``) — re-trying with the
|
|
20
|
+
same key wastes server time and can trigger abuse mitigations.
|
|
21
|
+
|
|
22
|
+
Out of scope:
|
|
23
|
+
* POSTing daemon→server frames — that's ``http_outbound.HTTPOutbound``.
|
|
24
|
+
|
|
25
|
+
Stale-stream watchdogs (1k #64 passive + Wave C #2 active)
|
|
26
|
+
----------------------------------------------------------
|
|
27
|
+
Clash TUN / NAT middleware silently swallows long-idle SSE streams: the
|
|
28
|
+
TCP socket stays open from our side but no bytes ever arrive again. Two
|
|
29
|
+
layers of defence:
|
|
30
|
+
|
|
31
|
+
* **Passive (1k #64)** — httpx's ``read`` timeout. The server sends
|
|
32
|
+
``:hb`` comment lines every 30s when idle (see
|
|
33
|
+
``backend/.../daemon_sse.py``), which counts as bytes at the socket
|
|
34
|
+
layer (``httpx-sse`` filters comment lines out only at the parse
|
|
35
|
+
layer). If no bytes arrive within ``READ_TIMEOUT_SECONDS`` (60s = 2×
|
|
36
|
+
server heartbeat), httpx raises ``ReadTimeout`` and we reconnect with
|
|
37
|
+
``Last-Event-ID``. Covers "TCP socket dead, no bytes flowing".
|
|
38
|
+
|
|
39
|
+
* **Active (Wave C #2)** — :data:`ACTIVE_LIVENESS_TIMEOUT_SECONDS`
|
|
40
|
+
watchdog **on the parsed-event stream**. ``httpx-sse`` filters
|
|
41
|
+
``:hb`` comments before yielding to user code, so even on a fully
|
|
42
|
+
healthy stream we may go minutes between user-visible events. The
|
|
43
|
+
passive timeout (TCP bytes) can hide a pathological case where the
|
|
44
|
+
underlying httpx-sse buffer is starved of real events forever. If we
|
|
45
|
+
see no real frame for ``ACTIVE_LIVENESS_TIMEOUT_SECONDS`` we close
|
|
46
|
+
the stream from our side and reconnect — the next ``Last-Event-ID``
|
|
47
|
+
GET re-arms the catchup buffer and the worst-case stuck-frame window
|
|
48
|
+
is bounded by the watchdog. Default is intentionally LONGER than the
|
|
49
|
+
passive timeout so an idle backend doesn't reconnect every minute.
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import asyncio
|
|
54
|
+
import json
|
|
55
|
+
import logging
|
|
56
|
+
import ssl
|
|
57
|
+
import time
|
|
58
|
+
from collections.abc import Awaitable, Callable
|
|
59
|
+
|
|
60
|
+
import httpx
|
|
61
|
+
from httpx_sse import aconnect_sse
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
# Reconnect backoff curve: 1, 2, 4, 8, 16, 30, 30, … (capped at 30s).
|
|
66
|
+
_BACKOFF_INITIAL_SECONDS = 1.0
|
|
67
|
+
_BACKOFF_CAP_SECONDS = 30.0
|
|
68
|
+
|
|
69
|
+
# Passive stale-stream watchdog (1k #64). Server emits a ``:hb`` comment
|
|
70
|
+
# line every 30s when no real frame flows; httpx counts each ``:hb`` as
|
|
71
|
+
# bytes at the socket layer (httpx-sse filters them out only at parse
|
|
72
|
+
# layer). 60s = tolerate one missed heartbeat before httpx raises
|
|
73
|
+
# ``ReadTimeout`` and we reconnect via ``Last-Event-ID``. Tests
|
|
74
|
+
# monkeypatch this down to a few seconds.
|
|
75
|
+
READ_TIMEOUT_SECONDS = 60.0
|
|
76
|
+
|
|
77
|
+
# Active liveness watchdog (Wave C #2). httpx-sse swallows ``:hb``
|
|
78
|
+
# comment lines before yielding to user code, so even on a healthy
|
|
79
|
+
# stream we may go minutes between user-visible events. If no parsed
|
|
80
|
+
# event arrives within this window, force-close the SSE response and
|
|
81
|
+
# trigger a fresh ``Last-Event-ID`` GET. Default is 180s = 6× server
|
|
82
|
+
# heartbeat — wide enough that a genuinely idle backend doesn't
|
|
83
|
+
# thrash, tight enough to cap stuck-event windows at 3 min in
|
|
84
|
+
# worst-case pathological httpx-sse buffer starvation. Tests
|
|
85
|
+
# monkeypatch this to a few seconds.
|
|
86
|
+
ACTIVE_LIVENESS_TIMEOUT_SECONDS = 180.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class AuthFailed(Exception):
|
|
90
|
+
"""Raised on terminal 401/403 — caller should not retry."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, status_code: int) -> None:
|
|
93
|
+
self.status_code = status_code
|
|
94
|
+
super().__init__(f"SSE auth failed: HTTP {status_code}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SSEClient:
|
|
98
|
+
"""Async SSE consumer with auto-reconnect via ``Last-Event-ID``.
|
|
99
|
+
|
|
100
|
+
Usage::
|
|
101
|
+
|
|
102
|
+
client = SSEClient(server_url, api_key)
|
|
103
|
+
await client.run_forever(on_frame)
|
|
104
|
+
|
|
105
|
+
``on_frame`` is awaited for each parsed JSON event (heartbeat comment
|
|
106
|
+
lines and non-JSON event data are silently dropped).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
server_url: str,
|
|
112
|
+
api_key: str,
|
|
113
|
+
*,
|
|
114
|
+
insecure: bool = False,
|
|
115
|
+
last_event_id: str = "0",
|
|
116
|
+
) -> None:
|
|
117
|
+
self._server_url = server_url.rstrip("/")
|
|
118
|
+
self._api_key = api_key
|
|
119
|
+
self._insecure = insecure
|
|
120
|
+
self._last_event_id = last_event_id
|
|
121
|
+
self._stop_event = asyncio.Event()
|
|
122
|
+
self._reconnects = 0
|
|
123
|
+
# Wave C #2 — active liveness watchdog timestamp. Reset on
|
|
124
|
+
# every successfully-parsed event (and on connection open).
|
|
125
|
+
# Compared against ``ACTIVE_LIVENESS_TIMEOUT_SECONDS`` inside
|
|
126
|
+
# ``_consume_sse_until_stop`` to force a reconnect when the
|
|
127
|
+
# event stream stalls below the passive read-timeout layer.
|
|
128
|
+
self._last_event_at: float = time.monotonic()
|
|
129
|
+
# Diagnostic counters surfaced by /metrics + doctor (Wave C #3).
|
|
130
|
+
self._active_watchdog_fires = 0
|
|
131
|
+
# SSL context only used for https:// URLs; httpx ignores ``verify``
|
|
132
|
+
# values that aren't bool/path/SSLContext, so we map insecure→False.
|
|
133
|
+
self._verify: ssl.SSLContext | bool = not insecure
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def last_event_id(self) -> str:
|
|
137
|
+
return self._last_event_id
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def reconnect_count(self) -> int:
|
|
141
|
+
return self._reconnects
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def last_event_age_seconds(self) -> float:
|
|
145
|
+
"""Seconds since the last parsed SSE event (Wave C #2 / #3).
|
|
146
|
+
|
|
147
|
+
Used by ``cloudagent-daemon doctor`` and the ``/metrics`` endpoint
|
|
148
|
+
to surface stream liveness without having to plumb the timestamp
|
|
149
|
+
through callbacks.
|
|
150
|
+
"""
|
|
151
|
+
return max(0.0, time.monotonic() - self._last_event_at)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def active_watchdog_fires(self) -> int:
|
|
155
|
+
"""How many times the active liveness watchdog has forced a
|
|
156
|
+
reconnect (cumulative since process start)."""
|
|
157
|
+
return self._active_watchdog_fires
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
"""Signal ``run_forever`` to exit at the next checkpoint."""
|
|
161
|
+
self._stop_event.set()
|
|
162
|
+
|
|
163
|
+
async def run_forever(
|
|
164
|
+
self,
|
|
165
|
+
on_frame: Callable[[dict], Awaitable[None]],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Connect, stream, reconnect on errors. Returns on ``stop()``.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
AuthFailed: 401/403 on the SSE GET — auth is dead, do not retry.
|
|
171
|
+
asyncio.CancelledError: propagated unchanged for cooperative
|
|
172
|
+
shutdown (so signal handlers / ``task.cancel()`` work).
|
|
173
|
+
"""
|
|
174
|
+
backoff = _BACKOFF_INITIAL_SECONDS
|
|
175
|
+
url = f"{self._server_url}/v1/daemon/stream"
|
|
176
|
+
|
|
177
|
+
# One client survives the entire run_forever call; httpx pools the
|
|
178
|
+
# underlying TCP connection so each reconnect is cheap.
|
|
179
|
+
# Read READ_TIMEOUT_SECONDS at call-time (not import-time) so tests
|
|
180
|
+
# can monkeypatch the module constant before invoking run_forever.
|
|
181
|
+
import cloudagent_daemon.sse_client as _self_module # local re-import
|
|
182
|
+
read_timeout = _self_module.READ_TIMEOUT_SECONDS
|
|
183
|
+
async with httpx.AsyncClient(
|
|
184
|
+
verify=self._verify,
|
|
185
|
+
# ``read=READ_TIMEOUT_SECONDS`` is the stale-stream watchdog —
|
|
186
|
+
# server sends ``:hb`` comments every 30s, so 60s of no inbound
|
|
187
|
+
# bytes means Clash TUN / NAT silently dropped the stream
|
|
188
|
+
# → httpx.ReadTimeout → reconnect with Last-Event-ID (1k #64).
|
|
189
|
+
timeout=httpx.Timeout(
|
|
190
|
+
timeout=None, connect=30.0, read=read_timeout
|
|
191
|
+
),
|
|
192
|
+
# No proxy override — daemon CLI's _bypass_system_proxy_by_default
|
|
193
|
+
# already cleared the env vars before we got here. Honour any
|
|
194
|
+
# explicit env var set by ``CLOUDAGENT_USE_SYSTEM_PROXY=1`` users.
|
|
195
|
+
trust_env=True,
|
|
196
|
+
# 2026-05-15 — Alibaba vipserver fronts the pre/daily/prod
|
|
197
|
+
# intranet ingresses (``pre-agentinfra.alibabacloud.com`` →
|
|
198
|
+
# vipserver TLS terminate → SLB → nginx-ingress). Vipserver
|
|
199
|
+
# buffers HTTP/1.1 chunked-transfer bodies, so the SSE long-
|
|
200
|
+
# poll yields zero bytes until the upstream closes — the
|
|
201
|
+
# daemon's stale-stream watchdog then fires every 60s and the
|
|
202
|
+
# stream looks broken even though the server is happily
|
|
203
|
+
# publishing frames into Redis. Negotiating HTTP/2 dodges
|
|
204
|
+
# the chunked-transfer path entirely (HTTP/2 frames the body
|
|
205
|
+
# natively). Cleartext HTTP and servers without h2 just
|
|
206
|
+
# transparently fall back to 1.1 (httpx ALPN).
|
|
207
|
+
http2=True,
|
|
208
|
+
) as client:
|
|
209
|
+
while not self._stop_event.is_set():
|
|
210
|
+
try:
|
|
211
|
+
headers = {
|
|
212
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
213
|
+
"Accept": "text/event-stream",
|
|
214
|
+
"Last-Event-ID": self._last_event_id,
|
|
215
|
+
}
|
|
216
|
+
async with aconnect_sse(
|
|
217
|
+
client, "GET", url, headers=headers
|
|
218
|
+
) as event_source:
|
|
219
|
+
# Surface auth failures BEFORE entering the
|
|
220
|
+
# iteration loop — httpx-sse defers the status
|
|
221
|
+
# check to ``response.raise_for_status()``.
|
|
222
|
+
try:
|
|
223
|
+
event_source.response.raise_for_status()
|
|
224
|
+
except httpx.HTTPStatusError as exc:
|
|
225
|
+
await _handle_status_error(exc)
|
|
226
|
+
# _handle_status_error either raises AuthFailed
|
|
227
|
+
# or returns; non-auth status falls through to
|
|
228
|
+
# the backoff path below.
|
|
229
|
+
raise
|
|
230
|
+
# Iterate manually so we can cooperatively check
|
|
231
|
+
# stop_event between event arrivals — if the stream
|
|
232
|
+
# only emits heartbeat comments (which httpx-sse
|
|
233
|
+
# filters out before yielding), a plain ``async for``
|
|
234
|
+
# would block forever ignoring stop_event.
|
|
235
|
+
sse_iter = event_source.aiter_sse()
|
|
236
|
+
backoff = await _consume_sse_until_stop(
|
|
237
|
+
sse_iter,
|
|
238
|
+
on_frame,
|
|
239
|
+
self,
|
|
240
|
+
backoff,
|
|
241
|
+
)
|
|
242
|
+
except AuthFailed:
|
|
243
|
+
raise
|
|
244
|
+
except httpx.HTTPStatusError as exc:
|
|
245
|
+
# Already handled above for 401/403; non-auth status
|
|
246
|
+
# codes (5xx, 502, etc.) are transient — back off.
|
|
247
|
+
logger.warning(
|
|
248
|
+
"sse: HTTP %d, reconnecting in %.1fs",
|
|
249
|
+
exc.response.status_code,
|
|
250
|
+
min(backoff, _BACKOFF_CAP_SECONDS),
|
|
251
|
+
)
|
|
252
|
+
except asyncio.CancelledError:
|
|
253
|
+
raise
|
|
254
|
+
except httpx.ReadTimeout:
|
|
255
|
+
# 1k #64 — stale-stream watchdog fired. Clash TUN / NAT
|
|
256
|
+
# silently dropped the stream; the OS-level read sat
|
|
257
|
+
# idle past READ_TIMEOUT_SECONDS without even a ``:hb``
|
|
258
|
+
# comment line arriving. Reconnect via Last-Event-ID.
|
|
259
|
+
if self._stop_event.is_set():
|
|
260
|
+
break
|
|
261
|
+
logger.warning(
|
|
262
|
+
"sse: stream read timeout (%.0fs idle) — "
|
|
263
|
+
"reconnecting via Last-Event-ID=%s in %.1fs",
|
|
264
|
+
read_timeout,
|
|
265
|
+
self._last_event_id,
|
|
266
|
+
min(backoff, _BACKOFF_CAP_SECONDS),
|
|
267
|
+
)
|
|
268
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
269
|
+
if self._stop_event.is_set():
|
|
270
|
+
break
|
|
271
|
+
logger.warning(
|
|
272
|
+
"sse: stream broken (%s), reconnecting in %.1fs",
|
|
273
|
+
type(exc).__name__,
|
|
274
|
+
min(backoff, _BACKOFF_CAP_SECONDS),
|
|
275
|
+
)
|
|
276
|
+
except Exception: # noqa: BLE001
|
|
277
|
+
# 1k78 — catch-all so silent failures stop being
|
|
278
|
+
# silent. Previously an unexpected exception type
|
|
279
|
+
# (e.g. RuntimeError from httpx-sse internals or
|
|
280
|
+
# ``on_frame`` raising) would propagate out of
|
|
281
|
+
# run_forever without hitting any logger — making
|
|
282
|
+
# post-mortem debugging impossible. Now we log
|
|
283
|
+
# with full traceback and re-raise so the
|
|
284
|
+
# supervisor in main_loop sees the task die.
|
|
285
|
+
if self._stop_event.is_set():
|
|
286
|
+
raise
|
|
287
|
+
logger.exception(
|
|
288
|
+
"sse: unexpected error in run_forever (will "
|
|
289
|
+
"propagate to supervisor for daemon restart)"
|
|
290
|
+
)
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
if self._stop_event.is_set():
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
self._reconnects += 1
|
|
297
|
+
# Wave C #3 — surface reconnects to the default metrics
|
|
298
|
+
# registry. Imported lazily so this module stays cheap
|
|
299
|
+
# to import in tests that don't exercise metrics.
|
|
300
|
+
try:
|
|
301
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
302
|
+
await _md().inc_counter(
|
|
303
|
+
"cloudagent_daemon_sse_reconnects_total"
|
|
304
|
+
)
|
|
305
|
+
except Exception: # noqa: BLE001
|
|
306
|
+
pass
|
|
307
|
+
await asyncio.sleep(min(backoff, _BACKOFF_CAP_SECONDS))
|
|
308
|
+
backoff = min(backoff * 2.0, _BACKOFF_CAP_SECONDS)
|
|
309
|
+
|
|
310
|
+
# 1k78 — if we exit the loop via stop_event normally, log
|
|
311
|
+
# that explicitly. Pre-1k78 a normal return looked identical
|
|
312
|
+
# to a silent failure in the main_loop supervisor.
|
|
313
|
+
logger.info(
|
|
314
|
+
"sse: run_forever exited normally (stop_event=%s reconnects=%d)",
|
|
315
|
+
self._stop_event.is_set(),
|
|
316
|
+
self._reconnects,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def _handle_status_error(exc: httpx.HTTPStatusError) -> None:
|
|
321
|
+
"""Raise AuthFailed for terminal codes; fall through for transient."""
|
|
322
|
+
code = exc.response.status_code
|
|
323
|
+
if code in (401, 403):
|
|
324
|
+
logger.error("sse: auth failed (%d), aborting", code)
|
|
325
|
+
raise AuthFailed(code) from exc
|
|
326
|
+
# Non-auth status — let the caller handle backoff.
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def _consume_sse_until_stop(
|
|
330
|
+
sse_iter,
|
|
331
|
+
on_frame: Callable[[dict], Awaitable[None]],
|
|
332
|
+
sse_client: "SSEClient",
|
|
333
|
+
backoff: float,
|
|
334
|
+
) -> float:
|
|
335
|
+
"""Drive ``sse_iter`` while honouring ``sse_client._stop_event``.
|
|
336
|
+
|
|
337
|
+
Returns the (possibly reset) backoff so the outer reconnect loop sees
|
|
338
|
+
the latest value after a successful event delivery.
|
|
339
|
+
|
|
340
|
+
Wave C #2 — also gates each ``__anext__`` call on the active
|
|
341
|
+
liveness watchdog: if no parsed event arrives in
|
|
342
|
+
:data:`ACTIVE_LIVENESS_TIMEOUT_SECONDS` (re-read from the module
|
|
343
|
+
each iteration so tests can monkeypatch), we exit so the outer
|
|
344
|
+
loop reconnects via ``Last-Event-ID``. Resetting ``_last_event_at``
|
|
345
|
+
on connection open + each successfully-parsed event keeps an idle
|
|
346
|
+
backend from thrashing.
|
|
347
|
+
"""
|
|
348
|
+
# Reset the watchdog clock when we (re)enter the consume loop —
|
|
349
|
+
# otherwise a previous run's last_event_at could still be stale
|
|
350
|
+
# at the moment we connect.
|
|
351
|
+
sse_client._last_event_at = time.monotonic()
|
|
352
|
+
while True:
|
|
353
|
+
if sse_client._stop_event.is_set():
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
# Re-read each iter so tests can monkeypatch the module-level
|
|
357
|
+
# constant between runs without reconstructing the client.
|
|
358
|
+
import cloudagent_daemon.sse_client as _self_module
|
|
359
|
+
active_timeout = _self_module.ACTIVE_LIVENESS_TIMEOUT_SECONDS
|
|
360
|
+
elapsed = time.monotonic() - sse_client._last_event_at
|
|
361
|
+
remaining = active_timeout - elapsed
|
|
362
|
+
if remaining <= 0:
|
|
363
|
+
# Edge case: clock jumped (sleep / hibernate) past the
|
|
364
|
+
# threshold while we were between iterations. Treat
|
|
365
|
+
# exactly like a watchdog fire.
|
|
366
|
+
sse_client._active_watchdog_fires += 1
|
|
367
|
+
try:
|
|
368
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
369
|
+
await _md().inc_counter(
|
|
370
|
+
"cloudagent_daemon_sse_active_watchdog_fires_total"
|
|
371
|
+
)
|
|
372
|
+
except Exception: # noqa: BLE001
|
|
373
|
+
pass
|
|
374
|
+
logger.warning(
|
|
375
|
+
"sse: active watchdog — no event in %.0fs (>= %.0fs), "
|
|
376
|
+
"reconnecting via Last-Event-ID=%s",
|
|
377
|
+
elapsed, active_timeout, sse_client._last_event_id,
|
|
378
|
+
)
|
|
379
|
+
return backoff
|
|
380
|
+
|
|
381
|
+
next_task = asyncio.create_task(_anext(sse_iter))
|
|
382
|
+
stop_task = asyncio.create_task(sse_client._stop_event.wait())
|
|
383
|
+
watchdog_task = asyncio.create_task(asyncio.sleep(remaining))
|
|
384
|
+
try:
|
|
385
|
+
done, _pending = await asyncio.wait(
|
|
386
|
+
{next_task, stop_task, watchdog_task},
|
|
387
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
388
|
+
)
|
|
389
|
+
finally:
|
|
390
|
+
for t in (next_task, stop_task, watchdog_task):
|
|
391
|
+
if not t.done():
|
|
392
|
+
t.cancel()
|
|
393
|
+
if stop_task in done:
|
|
394
|
+
return backoff
|
|
395
|
+
if watchdog_task in done and next_task not in done:
|
|
396
|
+
# Active watchdog won the race — the parsed-event stream
|
|
397
|
+
# has been silent past the threshold. Exit so the outer
|
|
398
|
+
# loop reconnects; ``Last-Event-ID`` re-arms catchup.
|
|
399
|
+
sse_client._active_watchdog_fires += 1
|
|
400
|
+
try:
|
|
401
|
+
from cloudagent_daemon.metrics import get_default as _md
|
|
402
|
+
await _md().inc_counter(
|
|
403
|
+
"cloudagent_daemon_sse_active_watchdog_fires_total"
|
|
404
|
+
)
|
|
405
|
+
except Exception: # noqa: BLE001
|
|
406
|
+
pass
|
|
407
|
+
elapsed = time.monotonic() - sse_client._last_event_at
|
|
408
|
+
logger.warning(
|
|
409
|
+
"sse: active watchdog — no event in %.0fs (>= %.0fs), "
|
|
410
|
+
"reconnecting via Last-Event-ID=%s",
|
|
411
|
+
elapsed, active_timeout, sse_client._last_event_id,
|
|
412
|
+
)
|
|
413
|
+
return backoff
|
|
414
|
+
# next_task done — propagate exceptions, otherwise consume.
|
|
415
|
+
try:
|
|
416
|
+
sse_event = next_task.result()
|
|
417
|
+
except StopAsyncIteration:
|
|
418
|
+
return backoff
|
|
419
|
+
sse_client._last_event_at = time.monotonic()
|
|
420
|
+
if sse_event.id:
|
|
421
|
+
sse_client._last_event_id = sse_event.id
|
|
422
|
+
if not sse_event.data:
|
|
423
|
+
continue
|
|
424
|
+
try:
|
|
425
|
+
frame = json.loads(sse_event.data)
|
|
426
|
+
except json.JSONDecodeError:
|
|
427
|
+
logger.warning(
|
|
428
|
+
"sse: non-JSON event data %r",
|
|
429
|
+
sse_event.data[:120],
|
|
430
|
+
)
|
|
431
|
+
continue
|
|
432
|
+
backoff = _BACKOFF_INITIAL_SECONDS
|
|
433
|
+
await on_frame(frame)
|
|
434
|
+
return backoff
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def _anext(aiter):
|
|
438
|
+
"""Polyfill for ``anext`` on a generic async iterator."""
|
|
439
|
+
return await aiter.__anext__()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Transport abstraction for daemon→server frames.
|
|
2
|
+
|
|
3
|
+
Sub-plan 1j replaces the WebSocket with an SSE stream (server→daemon)
|
|
4
|
+
plus per-frame-type POST endpoints (daemon→server). Outbound senders
|
|
5
|
+
(``OutboundQueue``, ``ClaudeRunner``) shouldn't care which wire is used —
|
|
6
|
+
they call ``transport.send_frame(frame_dict)`` and let the implementation
|
|
7
|
+
route.
|
|
8
|
+
|
|
9
|
+
Two concrete transports today:
|
|
10
|
+
* ``HTTPTransport`` — routes each frame ``type`` to the matching POST
|
|
11
|
+
endpoint. Used in production with the SSE stream.
|
|
12
|
+
* ``WSTransport`` — wraps a connected ``WSClient`` so the legacy
|
|
13
|
+
``--transport ws`` path still works through the same OutboundQueue.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import TYPE_CHECKING, Protocol
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .http_outbound import HTTPOutbound
|
|
22
|
+
from .ws_client import WSClient
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Transport(Protocol):
|
|
28
|
+
"""Sends daemon→server frames.
|
|
29
|
+
|
|
30
|
+
Implementations decide whether the frame goes over WS as JSON or
|
|
31
|
+
over HTTP as a POST to a per-type endpoint.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def send_frame(self, frame: dict) -> None: ...
|
|
35
|
+
|
|
36
|
+
async def close(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Frame ``type`` → POST path. ``None`` means the SSE path doesn't have a
|
|
40
|
+
# corresponding POST (the WS-only frame is silently dropped). We list all
|
|
41
|
+
# the daemon→server frame types from spec §4.2 explicitly so future frame
|
|
42
|
+
# types fail loudly (logged) rather than getting silently sent to ``None``.
|
|
43
|
+
_HTTP_PATHS: dict[str, str | None] = {
|
|
44
|
+
"delivery_ack": "/v1/daemon/delivery-ack",
|
|
45
|
+
"agent_status": "/v1/daemon/agent-status",
|
|
46
|
+
"agent_session_start": "/v1/daemon/agent-session-start",
|
|
47
|
+
"tool_call": "/v1/daemon/tool-call",
|
|
48
|
+
# SSE provides server-side ``:hb`` heartbeat; the daemon doesn't need
|
|
49
|
+
# to pong (and the server has no /pong endpoint). The legacy WS still
|
|
50
|
+
# uses pong via WSTransport.
|
|
51
|
+
"pong": None,
|
|
52
|
+
# Resume happens implicitly via the ``Last-Event-ID`` header on the
|
|
53
|
+
# SSE GET; no POST equivalent.
|
|
54
|
+
"resume": None,
|
|
55
|
+
# Ready is sent ONCE during /v1/daemon/ready (handled by main_loop
|
|
56
|
+
# before the OutboundQueue is wired up); no need to re-send.
|
|
57
|
+
"ready": None,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HTTPTransport:
|
|
62
|
+
"""Transport implementation that routes by frame type to POST endpoints."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, http: HTTPOutbound) -> None:
|
|
65
|
+
self._http = http
|
|
66
|
+
|
|
67
|
+
async def send_frame(self, frame: dict) -> None:
|
|
68
|
+
ftype = frame.get("type")
|
|
69
|
+
if ftype not in _HTTP_PATHS:
|
|
70
|
+
logger.warning(
|
|
71
|
+
"http_transport: unknown frame type %r — dropping", ftype
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
path = _HTTP_PATHS[ftype]
|
|
75
|
+
if path is None:
|
|
76
|
+
# Intentionally not POSTed — see _HTTP_PATHS comments above.
|
|
77
|
+
logger.debug(
|
|
78
|
+
"http_transport: frame type %r has no POST path — skipping",
|
|
79
|
+
ftype,
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
payload = frame.get("payload") or {}
|
|
83
|
+
# tool_call and delivery_ack include the request_id / message_id
|
|
84
|
+
# inside payload already; the backend POST schemas mirror those
|
|
85
|
+
# fields, so passing the inner payload as the JSON body is enough.
|
|
86
|
+
await self._http.post(path, payload)
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
await self._http.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WSTransport:
|
|
93
|
+
"""Transport wrapping a connected ``WSClient`` for the legacy path."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, ws: "WSClient") -> None:
|
|
96
|
+
self._ws = ws
|
|
97
|
+
|
|
98
|
+
async def send_frame(self, frame: dict) -> None:
|
|
99
|
+
await self._ws.send(frame)
|
|
100
|
+
|
|
101
|
+
async def close(self) -> None:
|
|
102
|
+
# WSClient is owned (entered/exited) by ``run_forever`` in the
|
|
103
|
+
# legacy path; closing here would race with the supervisor. Keep
|
|
104
|
+
# this a no-op so the supervisor stays in charge of the lifecycle.
|
|
105
|
+
return
|