alter-runtime 0.3.0__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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""McpFallbackSubscriber - direct MCP polling when the DO is unreachable (D-RT9).
|
|
2
|
+
|
|
3
|
+
When the primary :class:`DoSseSubscriber` is connected to the edge, this
|
|
4
|
+
subscriber is idle. When it transitions to disconnected (via the
|
|
5
|
+
``identity.disconnected`` bus sentinel), the fallback starts polling the
|
|
6
|
+
backend MCP endpoint at :attr:`DaemonConfig.mcp_fallback_endpoint` and
|
|
7
|
+
publishes synthetic frames onto the bus so that local surfaces (Unix socket,
|
|
8
|
+
D-Bus, inbox projection) never notice the edge is down.
|
|
9
|
+
|
|
10
|
+
The fallback calls the ``alter_whoami`` MCP tool - the one stable, publicly
|
|
11
|
+
documented read-side call on the backend that returns the authenticated
|
|
12
|
+
identity's current attunement / consent-tier / trait-snapshot header. We treat
|
|
13
|
+
the response as a ``state_sync`` synthetic event, synthesise an
|
|
14
|
+
:class:`SSEFrame`, and publish to both ``identity.frame`` and
|
|
15
|
+
``identity.event`` (mirroring what the DO SSE path does).
|
|
16
|
+
|
|
17
|
+
When the DO reconnects (``identity.connected``), polling stops until the next
|
|
18
|
+
disconnect. This matches D-RT9: "graceful fallback to direct MCP polling when
|
|
19
|
+
the edge is unreachable" - and the surfaces never know which path served them.
|
|
20
|
+
|
|
21
|
+
Design notes
|
|
22
|
+
------------
|
|
23
|
+
|
|
24
|
+
* **JSON-RPC envelope.** The backend MCP endpoint speaks JSON-RPC 2.0 over
|
|
25
|
+
HTTP POST. We send ``{"jsonrpc": "2.0", "method": "tools/call", "params":
|
|
26
|
+
{"name": "alter_whoami", "arguments": {}}, "id": <counter>}``.
|
|
27
|
+
* **State deduplication.** We cache the hash of the last published state
|
|
28
|
+
dict and skip publishing if the new state is identical - polling produces
|
|
29
|
+
noise otherwise. Tests exercise this.
|
|
30
|
+
* **Auth propagation.** The backend MCP tool accepts the alter-cli JWT as
|
|
31
|
+
the ``Authorization`` bearer header.
|
|
32
|
+
* **Back-off.** If the fallback itself is failing (backend MCP is also down),
|
|
33
|
+
we slow the poll rate with exponential backoff up to one minute between
|
|
34
|
+
attempts, and never log at ERROR - the disconnected state is already
|
|
35
|
+
logged by ``DoSseSubscriber``.
|
|
36
|
+
* **Cold start.** The fallback starts in *idle* mode. If the DO has never
|
|
37
|
+
connected, it waits for the first explicit ``identity.disconnected``
|
|
38
|
+
signal before polling. There is a separate cold-start timer in
|
|
39
|
+
:meth:`run` that activates the fallback if no ``identity.connected`` arrives
|
|
40
|
+
within ``fallback_trigger_after_seconds * 3`` of startup, which covers the
|
|
41
|
+
case where the primary subscriber can never establish a connection at all.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import contextlib
|
|
48
|
+
import hashlib
|
|
49
|
+
import json
|
|
50
|
+
import logging
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from typing import TYPE_CHECKING, Any
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
|
|
56
|
+
from alter_runtime.config import DaemonConfig
|
|
57
|
+
from alter_runtime.daemon import Component
|
|
58
|
+
from alter_runtime.http_auth import backend_default_headers
|
|
59
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
60
|
+
from alter_runtime.subscribers.do_sse import _build_tls_context
|
|
61
|
+
from alter_runtime.subscribers.sse import SSEFrame
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from alter_runtime.config import Session
|
|
65
|
+
|
|
66
|
+
__all__ = ["McpFallbackSubscriber"]
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger("alter_runtime.subscribers.mcp_fallback")
|
|
69
|
+
|
|
70
|
+
TOPIC_CONNECTED = "identity.connected"
|
|
71
|
+
TOPIC_DISCONNECTED = "identity.disconnected"
|
|
72
|
+
TOPIC_FRAME = "identity.frame"
|
|
73
|
+
TOPIC_EVENT = "identity.event"
|
|
74
|
+
|
|
75
|
+
#: Upper bound on the fallback's own exponential backoff when the MCP
|
|
76
|
+
#: endpoint is also failing.
|
|
77
|
+
MAX_POLL_BACKOFF_SECONDS: float = 60.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _FallbackState:
|
|
82
|
+
"""Internal state for the fallback subscriber (exposed for tests)."""
|
|
83
|
+
|
|
84
|
+
active: bool = False
|
|
85
|
+
poll_count: int = 0
|
|
86
|
+
last_state_hash: str | None = None
|
|
87
|
+
backoff: float = 0.0
|
|
88
|
+
history: list[str] = field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class McpFallbackSubscriber(Component):
|
|
92
|
+
"""Polls the backend MCP endpoint while the DO is unreachable.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
config:
|
|
97
|
+
Loaded :class:`DaemonConfig`. Uses ``mcp_fallback_endpoint``,
|
|
98
|
+
``fallback_poll_interval_seconds``, and ``fallback_trigger_after_seconds``.
|
|
99
|
+
session:
|
|
100
|
+
Authenticated alter-cli :class:`Session`. Used for the bearer JWT and
|
|
101
|
+
the handle (for logging and the synthetic frame ``id``).
|
|
102
|
+
bus:
|
|
103
|
+
Shared :class:`EventBus`. The fallback subscribes to connect/disconnect
|
|
104
|
+
sentinels and publishes synthetic frames/events.
|
|
105
|
+
http_client:
|
|
106
|
+
Optional ``httpx.AsyncClient`` override for tests.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
name = "mcp_fallback"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
config: DaemonConfig,
|
|
114
|
+
session: Session,
|
|
115
|
+
bus: EventBus,
|
|
116
|
+
*,
|
|
117
|
+
http_client: httpx.AsyncClient | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self._config = config
|
|
120
|
+
self._session = session
|
|
121
|
+
self._bus = bus
|
|
122
|
+
self._http_client = http_client
|
|
123
|
+
self._owns_client = http_client is None
|
|
124
|
+
self._stop_event = asyncio.Event()
|
|
125
|
+
self._state = _FallbackState()
|
|
126
|
+
self._activate_event = asyncio.Event()
|
|
127
|
+
self._request_id_counter = 0
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Component lifecycle
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def run(self) -> None:
|
|
134
|
+
"""Subscribe to bus sentinels and drive the polling loop."""
|
|
135
|
+
logger.info(
|
|
136
|
+
"mcp_fallback starting handle=%s endpoint=%s",
|
|
137
|
+
self._session.handle,
|
|
138
|
+
self._config.mcp_fallback_endpoint,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self._bus.subscribe(TOPIC_CONNECTED, self._on_do_connected)
|
|
142
|
+
self._bus.subscribe(TOPIC_DISCONNECTED, self._on_do_disconnected)
|
|
143
|
+
|
|
144
|
+
# Cold-start: if the DO never connects, activate the fallback after
|
|
145
|
+
# fallback_trigger_after_seconds * 3 so local surfaces still have
|
|
146
|
+
# something to render.
|
|
147
|
+
cold_start_deadline = max(self._config.fallback_trigger_after_seconds * 3.0, 10.0)
|
|
148
|
+
cold_start_task = asyncio.create_task(self._cold_start_timer(cold_start_deadline))
|
|
149
|
+
|
|
150
|
+
client = self._http_client or httpx.AsyncClient(
|
|
151
|
+
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
|
|
152
|
+
# Same strict TLS posture as the DO SSE path - CERT_REQUIRED,
|
|
153
|
+
# check_hostname=True, TLS 1.2 minimum. Closes runtime/M-1
|
|
154
|
+
# from pentest-findings-2026-04-15.md.
|
|
155
|
+
verify=_build_tls_context(),
|
|
156
|
+
# Backend default headers — CF Access service-token bundle
|
|
157
|
+
# (D-SUBSTRATE-UNIFIED-1 §2.3 Option A) merged with the
|
|
158
|
+
# canonical ``X-Alter-Client-*`` identity headers
|
|
159
|
+
# (D-MIN-VERSION-FLOOR-1 §3) so the server-side floor
|
|
160
|
+
# middleware can identify the daemon.
|
|
161
|
+
headers=backend_default_headers(),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
while not self._stop_event.is_set():
|
|
166
|
+
# Idle until someone activates us.
|
|
167
|
+
try:
|
|
168
|
+
await asyncio.wait_for(
|
|
169
|
+
self._activate_event.wait(),
|
|
170
|
+
timeout=None,
|
|
171
|
+
)
|
|
172
|
+
except asyncio.CancelledError:
|
|
173
|
+
raise
|
|
174
|
+
|
|
175
|
+
if self._stop_event.is_set():
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
await self._poll_loop(client)
|
|
179
|
+
finally:
|
|
180
|
+
cold_start_task.cancel()
|
|
181
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
182
|
+
await cold_start_task
|
|
183
|
+
|
|
184
|
+
self._bus.unsubscribe(TOPIC_CONNECTED, self._on_do_connected)
|
|
185
|
+
self._bus.unsubscribe(TOPIC_DISCONNECTED, self._on_do_disconnected)
|
|
186
|
+
|
|
187
|
+
if self._owns_client:
|
|
188
|
+
try:
|
|
189
|
+
await client.aclose()
|
|
190
|
+
except Exception: # pragma: no cover
|
|
191
|
+
pass
|
|
192
|
+
logger.info("mcp_fallback stopped handle=%s", self._session.handle)
|
|
193
|
+
|
|
194
|
+
async def stop(self) -> None:
|
|
195
|
+
"""Cooperative shutdown."""
|
|
196
|
+
self._stop_event.set()
|
|
197
|
+
self._activate_event.set() # unwedge the idle wait
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Bus callbacks
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
async def _on_do_disconnected(self, payload: dict[str, Any]) -> None:
|
|
204
|
+
"""Primary subscriber reported a disconnect - activate polling."""
|
|
205
|
+
if self._state.active:
|
|
206
|
+
return
|
|
207
|
+
self._state.active = True
|
|
208
|
+
self._state.history.append("activate")
|
|
209
|
+
logger.info(
|
|
210
|
+
"mcp_fallback activating handle=%s reason=%s",
|
|
211
|
+
self._session.handle,
|
|
212
|
+
payload.get("reason"),
|
|
213
|
+
)
|
|
214
|
+
self._activate_event.set()
|
|
215
|
+
|
|
216
|
+
async def _on_do_connected(self, payload: dict[str, Any]) -> None:
|
|
217
|
+
"""Primary subscriber reconnected - stand down."""
|
|
218
|
+
if not self._state.active:
|
|
219
|
+
return
|
|
220
|
+
self._state.active = False
|
|
221
|
+
self._state.history.append("deactivate")
|
|
222
|
+
logger.info(
|
|
223
|
+
"mcp_fallback standing down handle=%s (DO reconnected)",
|
|
224
|
+
self._session.handle,
|
|
225
|
+
)
|
|
226
|
+
# Clear the activate event so the next wait_for blocks again.
|
|
227
|
+
self._activate_event.clear()
|
|
228
|
+
|
|
229
|
+
async def _cold_start_timer(self, seconds: float) -> None:
|
|
230
|
+
"""Activate the fallback if the DO never connected at startup."""
|
|
231
|
+
try:
|
|
232
|
+
await asyncio.sleep(seconds)
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
return
|
|
235
|
+
if self._stop_event.is_set():
|
|
236
|
+
return
|
|
237
|
+
if not self._state.active and self._state.poll_count == 0:
|
|
238
|
+
logger.warning(
|
|
239
|
+
"mcp_fallback cold-start: DO has not connected in %.0fs - "
|
|
240
|
+
"activating fallback polling",
|
|
241
|
+
seconds,
|
|
242
|
+
)
|
|
243
|
+
self._state.active = True
|
|
244
|
+
self._state.history.append("cold_start")
|
|
245
|
+
self._activate_event.set()
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Polling loop
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
async def _poll_loop(self, client: httpx.AsyncClient) -> None:
|
|
252
|
+
"""While active, poll MCP every N seconds and publish synthetic frames."""
|
|
253
|
+
while self._state.active and not self._stop_event.is_set():
|
|
254
|
+
try:
|
|
255
|
+
state = await self._poll_once(client)
|
|
256
|
+
except asyncio.CancelledError:
|
|
257
|
+
raise
|
|
258
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
259
|
+
await self._on_poll_error(exc)
|
|
260
|
+
await self._sleep_interruptible(self._state.backoff)
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if state is not None:
|
|
264
|
+
await self._publish_state(state)
|
|
265
|
+
|
|
266
|
+
# Reset backoff on success
|
|
267
|
+
self._state.backoff = 0.0
|
|
268
|
+
await self._sleep_interruptible(self._config.fallback_poll_interval_seconds)
|
|
269
|
+
|
|
270
|
+
async def _poll_once(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
|
271
|
+
"""Issue one JSON-RPC call and return the result dict, or None on empty."""
|
|
272
|
+
self._request_id_counter += 1
|
|
273
|
+
body = {
|
|
274
|
+
"jsonrpc": "2.0",
|
|
275
|
+
"id": self._request_id_counter,
|
|
276
|
+
"method": "tools/call",
|
|
277
|
+
"params": {
|
|
278
|
+
"name": "alter_whoami",
|
|
279
|
+
"arguments": {},
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
headers = {
|
|
283
|
+
"Authorization": f"Bearer {self._session.jwt}",
|
|
284
|
+
"Content-Type": "application/json",
|
|
285
|
+
"Accept": "application/json",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
logger.debug("mcp_fallback polling endpoint=%s", self._config.mcp_fallback_endpoint)
|
|
289
|
+
response = await client.post(
|
|
290
|
+
self._config.mcp_fallback_endpoint,
|
|
291
|
+
json=body,
|
|
292
|
+
headers=headers,
|
|
293
|
+
)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
|
|
296
|
+
self._state.poll_count += 1
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
rpc_response = response.json()
|
|
300
|
+
except ValueError:
|
|
301
|
+
logger.warning("mcp_fallback non-JSON response from MCP endpoint")
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
if not isinstance(rpc_response, dict):
|
|
305
|
+
return None
|
|
306
|
+
if "error" in rpc_response and rpc_response.get("error"):
|
|
307
|
+
err = rpc_response["error"]
|
|
308
|
+
logger.warning(
|
|
309
|
+
"mcp_fallback JSON-RPC error code=%s message=%s",
|
|
310
|
+
err.get("code") if isinstance(err, dict) else err,
|
|
311
|
+
err.get("message") if isinstance(err, dict) else err,
|
|
312
|
+
)
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
result = rpc_response.get("result")
|
|
316
|
+
if not isinstance(result, dict):
|
|
317
|
+
return None
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
async def _on_poll_error(self, exc: Exception) -> None:
|
|
321
|
+
"""Increase backoff and log. Does not change the active flag."""
|
|
322
|
+
self._state.backoff = min(
|
|
323
|
+
max(self._state.backoff * 2 if self._state.backoff else 2.0, 2.0),
|
|
324
|
+
MAX_POLL_BACKOFF_SECONDS,
|
|
325
|
+
)
|
|
326
|
+
logger.warning(
|
|
327
|
+
"mcp_fallback poll failed: %s - backoff %.1fs",
|
|
328
|
+
exc,
|
|
329
|
+
self._state.backoff,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# Publishing
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
async def _publish_state(self, state: dict[str, Any]) -> None:
|
|
337
|
+
"""Dedupe against the last published state and publish a synthetic frame."""
|
|
338
|
+
digest = _stable_hash(state)
|
|
339
|
+
if digest == self._state.last_state_hash:
|
|
340
|
+
logger.debug("mcp_fallback state unchanged - skip publish")
|
|
341
|
+
return
|
|
342
|
+
self._state.last_state_hash = digest
|
|
343
|
+
|
|
344
|
+
synthetic_event: dict[str, Any] = {
|
|
345
|
+
"kind": "state_sync",
|
|
346
|
+
"source": "mcp_fallback",
|
|
347
|
+
"handle": self._session.handle,
|
|
348
|
+
"payload": state,
|
|
349
|
+
}
|
|
350
|
+
frame = SSEFrame(
|
|
351
|
+
event="state_sync",
|
|
352
|
+
data=json.dumps(synthetic_event, separators=(",", ":")),
|
|
353
|
+
id=f"fallback-{self._state.poll_count}",
|
|
354
|
+
)
|
|
355
|
+
logger.info(
|
|
356
|
+
"mcp_fallback publishing state_sync poll=%d keys=%s",
|
|
357
|
+
self._state.poll_count,
|
|
358
|
+
sorted(state.keys())[:8],
|
|
359
|
+
)
|
|
360
|
+
await self._bus.publish(TOPIC_FRAME, frame)
|
|
361
|
+
await self._bus.publish(TOPIC_EVENT, synthetic_event)
|
|
362
|
+
|
|
363
|
+
async def _sleep_interruptible(self, seconds: float) -> None:
|
|
364
|
+
"""Wait ``seconds`` or until stopped / deactivated."""
|
|
365
|
+
if seconds <= 0:
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
# Wake early if either shutdown or reconnect fires.
|
|
369
|
+
await asyncio.wait_for(self._wait_stop_or_inactive(), timeout=seconds)
|
|
370
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
async def _wait_stop_or_inactive(self) -> None:
|
|
374
|
+
"""Return as soon as either the component stops or the fallback is deactivated."""
|
|
375
|
+
while not self._stop_event.is_set() and self._state.active:
|
|
376
|
+
await asyncio.sleep(0.05)
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
# Test introspection
|
|
380
|
+
# ------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def state(self) -> _FallbackState:
|
|
384
|
+
"""Current fallback state (used by tests)."""
|
|
385
|
+
return self._state
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _stable_hash(state: dict[str, Any]) -> str:
|
|
389
|
+
"""SHA-256 of a canonicalised JSON encoding of ``state`` (for dedupe)."""
|
|
390
|
+
canonical = json.dumps(state, sort_keys=True, separators=(",", ":"))
|
|
391
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|