@inline-chat/hermes-agent-adapter 1.0.0-alpha.3 → 1.0.0
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.
- package/dist/install.js +95 -95
- package/package.json +10 -4
- package/plugin/inline/adapter.py +95 -67
- package/plugin/inline/cli.py +9 -2
- package/plugin/inline/plugin.yaml +1 -1
- package/plugin/inline/sidecar/index.mjs +846 -801
- package/plugin/inline/tools.py +12 -2
package/plugin/inline/adapter.py
CHANGED
|
@@ -76,6 +76,16 @@ _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS = 2.0
|
|
|
76
76
|
_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS = 30.0
|
|
77
77
|
_INBOUND_RETRY_INITIAL_SECONDS = 1.0
|
|
78
78
|
_MAX_INBOUND_DELIVERIES = 16
|
|
79
|
+
# Telegram's drop_pending_on_cold_boot, for Inline: updates the server recorded
|
|
80
|
+
# before this gateway process started are acknowledged without waking the agent.
|
|
81
|
+
# A lost or fresh SDK cursor otherwise replays chat history as new messages.
|
|
82
|
+
_PROCESS_STARTED_AT = time.time()
|
|
83
|
+
_COLD_BOOT_CLOCK_SKEW_SECONDS = 60
|
|
84
|
+
_COLD_BOOT_DROP_LOG_INTERVAL_SECONDS = 60.0
|
|
85
|
+
_COLD_BOOT_GATED_KINDS = frozenset({
|
|
86
|
+
"message.new", "message.edit", "message.delete", "message.history.clear",
|
|
87
|
+
"reaction.add", "reaction.delete", "chat.participant.add", "chat.participant.delete",
|
|
88
|
+
})
|
|
79
89
|
# Provider discovery may invoke host CLIs. Keep repeated panel opens and
|
|
80
90
|
# unrelated mutations off that slow path while always injecting the live
|
|
81
91
|
# current/default model into the document below.
|
|
@@ -610,11 +620,14 @@ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[Li
|
|
|
610
620
|
|
|
611
621
|
def _inline_skill_catalog() -> List[Dict[str, Any]]:
|
|
612
622
|
"""Return installed, enabled Hermes skills in the Bot API catalog shape."""
|
|
613
|
-
|
|
623
|
+
import tools.skills_tool as skills_tool
|
|
614
624
|
|
|
625
|
+
# Hermes 0.22.5 made these public; older supported hosts keep the private names.
|
|
626
|
+
find_all = getattr(skills_tool, "find_all_skills", None) or getattr(skills_tool, "_find_all_skills")
|
|
627
|
+
sort = getattr(skills_tool, "sort_skills", None) or getattr(skills_tool, "_sort_skills")
|
|
615
628
|
published: List[Dict[str, Any]] = []
|
|
616
629
|
seen: set[str] = set()
|
|
617
|
-
for skill in
|
|
630
|
+
for skill in sort(find_all(skip_disabled=False)):
|
|
618
631
|
if len(published) >= 250:
|
|
619
632
|
break
|
|
620
633
|
key = str(skill.get("name") or "").strip()
|
|
@@ -915,6 +928,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
915
928
|
else inline_env("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
|
|
916
929
|
True,
|
|
917
930
|
)
|
|
931
|
+
self._drop_pending_on_cold_boot = _truthy(
|
|
932
|
+
extra.get("drop_pending_on_cold_boot")
|
|
933
|
+
if "drop_pending_on_cold_boot" in extra
|
|
934
|
+
else inline_env("INLINE_DROP_PENDING_ON_COLD_BOOT"),
|
|
935
|
+
True,
|
|
936
|
+
)
|
|
937
|
+
self._cold_boot_dropped = 0
|
|
938
|
+
self._cold_boot_drop_logged_at = 0.0
|
|
918
939
|
self._reply_thread_mode = _reply_thread_mode(
|
|
919
940
|
extra.get("reply_threads") if "reply_threads" in extra else inline_env("INLINE_REPLY_THREADS"),
|
|
920
941
|
"auto",
|
|
@@ -1015,7 +1036,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1015
1036
|
self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
|
|
1016
1037
|
self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
|
|
1017
1038
|
# Whether each request came from a bot, so answers to bots reply to them and reach them.
|
|
1018
|
-
self.
|
|
1039
|
+
self._request_quotes_sender: "OrderedDict[str, bool]" = OrderedDict()
|
|
1019
1040
|
# Commands this bot advertises in Inline's command menu, as last synced.
|
|
1020
1041
|
self._advertised_commands: set[str] = set()
|
|
1021
1042
|
self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
|
|
@@ -1179,7 +1200,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1179
1200
|
self, *, chat_id: str, chat_type: str, parent_chat_id: str, actor_id: str, actor_is_bot: bool, mode: str,
|
|
1180
1201
|
) -> Dict[str, Any]:
|
|
1181
1202
|
"""Change reply threads because the person talking to the agent asked, with the access
|
|
1182
|
-
/threads needs. Inside a reply thread it changes the parent chat, as /threads does
|
|
1203
|
+
/threads needs. Inside a reply thread it changes the parent chat, as /threads does,
|
|
1204
|
+
and chat_type is the parent's."""
|
|
1183
1205
|
if actor_is_bot or not actor_id:
|
|
1184
1206
|
raise PermissionError("only a person can change reply threads")
|
|
1185
1207
|
target_chat_id = parent_chat_id or chat_id
|
|
@@ -1190,6 +1212,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1190
1212
|
self._set_reply_threads_for_chat(target_chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
|
|
1191
1213
|
return {"chat_id": self._chat_key(target_chat_id), "mode": self._reply_thread_mode_for_chat(target_chat_id)}
|
|
1192
1214
|
|
|
1215
|
+
def chat_type_from_info(self, info: Dict[str, Any]) -> str:
|
|
1216
|
+
return self._bot_settings_chat_type(info)
|
|
1217
|
+
|
|
1193
1218
|
@staticmethod
|
|
1194
1219
|
def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
|
|
1195
1220
|
peer = info.get("peer")
|
|
@@ -1953,6 +1978,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1953
1978
|
self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
|
|
1954
1979
|
self._mark_connected()
|
|
1955
1980
|
logger.info("[inline] connected via sidecar on %s:%d", self._sidecar_bind, self._sidecar_port)
|
|
1981
|
+
if not is_reconnect:
|
|
1982
|
+
logger.info(
|
|
1983
|
+
"[inline] Cold boot: %s Inline updates recorded before this gateway started "
|
|
1984
|
+
"(drop_pending_on_cold_boot: %s)",
|
|
1985
|
+
"dropping" if self._drop_pending_on_cold_boot else "preserving",
|
|
1986
|
+
"true" if self._drop_pending_on_cold_boot else "false")
|
|
1956
1987
|
self._schedule_bot_command_sync()
|
|
1957
1988
|
return True
|
|
1958
1989
|
|
|
@@ -2393,6 +2424,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2393
2424
|
# owns a coherent client-state cache.
|
|
2394
2425
|
self._invalidate_chat_info(event.get("chatId"))
|
|
2395
2426
|
kind = event.get("kind")
|
|
2427
|
+
if kind in _COLD_BOOT_GATED_KINDS and self._recorded_before_cold_boot(event):
|
|
2428
|
+
return
|
|
2396
2429
|
if kind == "bot.chatSettings.request":
|
|
2397
2430
|
if event.get("_inlineDeliveryId"):
|
|
2398
2431
|
await self._handle_bot_settings_request(event)
|
|
@@ -2437,6 +2470,29 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2437
2470
|
return
|
|
2438
2471
|
await self._dispatch_message(event)
|
|
2439
2472
|
|
|
2473
|
+
def _recorded_before_cold_boot(self, event: Dict[str, Any]) -> bool:
|
|
2474
|
+
"""Whether this update predates the gateway process and must not wake the agent.
|
|
2475
|
+
|
|
2476
|
+
The update date is when the server recorded the change, so a live edit or reaction
|
|
2477
|
+
on an old message still passes. Updates without a date pass rather than risk
|
|
2478
|
+
dropping live work."""
|
|
2479
|
+
if not self._drop_pending_on_cold_boot:
|
|
2480
|
+
return False
|
|
2481
|
+
date = _to_int(event.get("date"))
|
|
2482
|
+
if date is None and event.get("kind") == "message.new":
|
|
2483
|
+
date = _to_int((event.get("message") or {}).get("date"))
|
|
2484
|
+
if date is None or date <= 0 or date >= _PROCESS_STARTED_AT - _COLD_BOOT_CLOCK_SKEW_SECONDS:
|
|
2485
|
+
return False
|
|
2486
|
+
self._cold_boot_dropped += 1
|
|
2487
|
+
now = time.time()
|
|
2488
|
+
if now - self._cold_boot_drop_logged_at >= _COLD_BOOT_DROP_LOG_INTERVAL_SECONDS:
|
|
2489
|
+
self._cold_boot_drop_logged_at = now
|
|
2490
|
+
logger.info(
|
|
2491
|
+
"[inline] dropped %d update(s) recorded before this gateway started; latest %s in chat %s, %ds before startup",
|
|
2492
|
+
self._cold_boot_dropped, event.get("kind"), event.get("chatId") or "?",
|
|
2493
|
+
int(_PROCESS_STARTED_AT - date))
|
|
2494
|
+
return True
|
|
2495
|
+
|
|
2440
2496
|
def _is_duplicate(self, key: str) -> bool:
|
|
2441
2497
|
now = time.time()
|
|
2442
2498
|
old = self._seen_messages.get(key)
|
|
@@ -2684,13 +2740,17 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2684
2740
|
if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
|
|
2685
2741
|
self._remember_observed_context(chat_id, msg, text)
|
|
2686
2742
|
return
|
|
2687
|
-
|
|
2743
|
+
sender_is_bot = sender_profile.get("bot") is True
|
|
2744
|
+
self._remember_request_sender(chat_id, msg_id, sender_is_bot and (explicitly_mentions_me or command_addressed_to_me))
|
|
2688
2745
|
if edit:
|
|
2689
2746
|
text = f"message:edited:{text}" if text else "message:edited"
|
|
2690
2747
|
if (
|
|
2691
2748
|
not edit
|
|
2692
2749
|
and not agent_action
|
|
2693
2750
|
and not thread_id
|
|
2751
|
+
# A bot's message is answered in place even in a chat set to On: it wakes on
|
|
2752
|
+
# replies in the chat it asked from, and a thread would never reach it.
|
|
2753
|
+
and sender_profile.get("bot") is not True
|
|
2694
2754
|
and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
|
|
2695
2755
|
):
|
|
2696
2756
|
created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
|
|
@@ -2770,26 +2830,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2770
2830
|
reply_context_messages=context_backfill["reply_context_messages"],
|
|
2771
2831
|
recent_messages=context_backfill["recent_messages"],
|
|
2772
2832
|
)
|
|
2773
|
-
metadata = self._inline_event_metadata(
|
|
2774
|
-
chat_id=chat_id,
|
|
2775
|
-
msg_id=msg_id,
|
|
2776
|
-
from_id=from_id,
|
|
2777
|
-
thread_id=thread_id,
|
|
2778
|
-
parent_chat_id=parent_chat_id,
|
|
2779
|
-
parent_message_id=parent_message_id,
|
|
2780
|
-
entity_text=entity_text,
|
|
2781
|
-
)
|
|
2782
|
-
if agent_action:
|
|
2783
|
-
metadata["inline"]["action"] = {
|
|
2784
|
-
"event_kind": "message.action.invoke",
|
|
2785
|
-
"actor_user_id": str(agent_action.get("actorUserId") or ""),
|
|
2786
|
-
"chat_id": str(agent_action.get("chatId") or ""),
|
|
2787
|
-
"target_message_id": str(agent_action.get("messageId") or ""),
|
|
2788
|
-
"interaction_id": str(agent_action.get("interactionId") or ""),
|
|
2789
|
-
"action_id": str(agent_action.get("actionId") or ""),
|
|
2790
|
-
"callback_data_base64": str(agent_action.get("dataBase64") or ""),
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
2833
|
session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
|
|
2794
2834
|
try:
|
|
2795
2835
|
from . import tools as _inline_tools
|
|
@@ -2812,7 +2852,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2812
2852
|
parent_chat_id=parent_chat_id,
|
|
2813
2853
|
message_id=msg_id,
|
|
2814
2854
|
)
|
|
2855
|
+
# A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
|
|
2856
|
+
# posting its "needed a reply" warning. Hosts before reply_expected ignore this.
|
|
2857
|
+
answer_from_bot = sender_is_bot and not explicitly_mentions_me and not command_addressed_to_me
|
|
2815
2858
|
await self.handle_message(MessageEvent(
|
|
2859
|
+
**({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
|
|
2816
2860
|
text=text,
|
|
2817
2861
|
message_type=message_type,
|
|
2818
2862
|
source=source,
|
|
@@ -2835,7 +2879,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2835
2879
|
auto_skill=auto_skill,
|
|
2836
2880
|
channel_prompt=channel_prompt,
|
|
2837
2881
|
channel_context=channel_context,
|
|
2838
|
-
metadata=metadata,
|
|
2839
2882
|
timestamp=self._timestamp(event.get("date") or msg.get("date")),
|
|
2840
2883
|
allow_gateway_control=not bool(agent_action),
|
|
2841
2884
|
))
|
|
@@ -3819,33 +3862,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3819
3862
|
prefix += " from an unknown sender"
|
|
3820
3863
|
return f"{prefix}: {text}"
|
|
3821
3864
|
|
|
3822
|
-
def _inline_event_metadata(
|
|
3823
|
-
self,
|
|
3824
|
-
*,
|
|
3825
|
-
chat_id: str,
|
|
3826
|
-
msg_id: str,
|
|
3827
|
-
from_id: str,
|
|
3828
|
-
thread_id: Optional[str],
|
|
3829
|
-
parent_chat_id: Optional[str],
|
|
3830
|
-
parent_message_id: Optional[str],
|
|
3831
|
-
entity_text: Optional[str],
|
|
3832
|
-
) -> Dict[str, Any]:
|
|
3833
|
-
inline: Dict[str, Any] = {
|
|
3834
|
-
"chat_id": self._chat_key(chat_id),
|
|
3835
|
-
"message_id": str(msg_id),
|
|
3836
|
-
}
|
|
3837
|
-
if from_id:
|
|
3838
|
-
inline["sender_user_id"] = self._chat_key(from_id)
|
|
3839
|
-
if thread_id:
|
|
3840
|
-
inline["thread_id"] = self._chat_key(thread_id)
|
|
3841
|
-
if parent_chat_id:
|
|
3842
|
-
inline["parent_chat_id"] = self._chat_key(parent_chat_id)
|
|
3843
|
-
if parent_message_id:
|
|
3844
|
-
inline["parent_message_id"] = str(parent_message_id)
|
|
3845
|
-
if entity_text:
|
|
3846
|
-
inline["message_entities"] = entity_text
|
|
3847
|
-
return {"inline": inline}
|
|
3848
|
-
|
|
3849
3865
|
@staticmethod
|
|
3850
3866
|
def _reply_thread_title(text: str) -> str:
|
|
3851
3867
|
title = strip_markdown(str(text or "")).strip()
|
|
@@ -3939,8 +3955,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3939
3955
|
async def _reply_to_for_target(
|
|
3940
3956
|
self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
|
|
3941
3957
|
) -> Optional[str]:
|
|
3942
|
-
"""Answers quote the message only when
|
|
3943
|
-
own messages, so that is how
|
|
3958
|
+
"""Answers quote the message only when a bot asked by addressing this one: a bot wakes on
|
|
3959
|
+
replies to its own messages, so that is how the answer reaches it. A bot's reply to this
|
|
3960
|
+
bot is that answer coming back and gets a plain response, otherwise two bots would quote
|
|
3961
|
+
each other forever; another round takes a new mention. People get plain answers."""
|
|
3944
3962
|
if not reply_to:
|
|
3945
3963
|
return None
|
|
3946
3964
|
target_chat_id = target.get("chatId") or ""
|
|
@@ -3951,8 +3969,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3951
3969
|
if anchored:
|
|
3952
3970
|
return str(reply_to)
|
|
3953
3971
|
key = f"{self._chat_key(target_chat_id)}:{reply_to}"
|
|
3954
|
-
|
|
3955
|
-
if
|
|
3972
|
+
quotes_sender = self._request_quotes_sender.get(key)
|
|
3973
|
+
if quotes_sender is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
|
|
3956
3974
|
info = await self._get_chat_info(target_chat_id)
|
|
3957
3975
|
if not info:
|
|
3958
3976
|
return None
|
|
@@ -3964,16 +3982,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3964
3982
|
# Message ids are per chat, so only look it up where the request was sent; an answer
|
|
3965
3983
|
# moved into a thread would otherwise read the thread's message with the same id.
|
|
3966
3984
|
fetched = await self._fetch_message(target_chat_id, str(reply_to))
|
|
3967
|
-
|
|
3968
|
-
self._remember_request_sender(target_chat_id, str(reply_to),
|
|
3969
|
-
return str(reply_to) if
|
|
3985
|
+
quotes_sender = bool(fetched) and _inline_sender_profile({}, fetched).get("bot") is True and self._message_entity_mentions_me(fetched)
|
|
3986
|
+
self._remember_request_sender(target_chat_id, str(reply_to), quotes_sender)
|
|
3987
|
+
return str(reply_to) if quotes_sender else None
|
|
3970
3988
|
|
|
3971
|
-
def _remember_request_sender(self, chat_id: str, msg_id: str,
|
|
3989
|
+
def _remember_request_sender(self, chat_id: str, msg_id: str, quotes_sender: bool) -> None:
|
|
3972
3990
|
key = f"{self._chat_key(chat_id)}:{msg_id}"
|
|
3973
|
-
self.
|
|
3974
|
-
self.
|
|
3975
|
-
while len(self.
|
|
3976
|
-
self.
|
|
3991
|
+
self._request_quotes_sender[key] = quotes_sender
|
|
3992
|
+
self._request_quotes_sender.move_to_end(key)
|
|
3993
|
+
while len(self._request_quotes_sender) > _CHAT_INFO_CACHE_MAX_SIZE:
|
|
3994
|
+
self._request_quotes_sender.popitem(last=False)
|
|
3977
3995
|
|
|
3978
3996
|
async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
|
|
3979
3997
|
target = _target_from_chat_id(chat_id)
|
|
@@ -5557,6 +5575,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5557
5575
|
if len(mapping) > limit:
|
|
5558
5576
|
mapping.popitem(last=False)
|
|
5559
5577
|
|
|
5578
|
+
def _send_retry_is_final(self, result: SendResult) -> bool:
|
|
5579
|
+
# The request reached Inline but its result was lost. A retry or plain-text
|
|
5580
|
+
# fallback would post the message twice; Hermes treats timeouts the same way.
|
|
5581
|
+
raw = getattr(result, "raw_response", None)
|
|
5582
|
+
return isinstance(raw, dict) and raw.get("outcomeUnknown") is True
|
|
5583
|
+
|
|
5560
5584
|
async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
|
|
5561
5585
|
try:
|
|
5562
5586
|
data = await self._sidecar_call(path, body)
|
|
@@ -5572,12 +5596,15 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5572
5596
|
except InlineSidecarError as exc:
|
|
5573
5597
|
if exc.error_kind == "unknown":
|
|
5574
5598
|
self._report_error("sidecar.send", exc)
|
|
5599
|
+
raw = exc.raw if isinstance(exc.raw, dict) else {}
|
|
5600
|
+
retry_after = _to_int(raw.get("retryAfterSeconds"))
|
|
5575
5601
|
return _send_result(
|
|
5576
5602
|
success=False,
|
|
5577
5603
|
error=str(exc),
|
|
5578
5604
|
raw_response=exc.raw,
|
|
5579
5605
|
retryable=exc.retryable,
|
|
5580
5606
|
error_kind=exc.error_kind,
|
|
5607
|
+
retry_after=float(retry_after) if retry_after and retry_after > 0 else None,
|
|
5581
5608
|
)
|
|
5582
5609
|
except Exception as exc:
|
|
5583
5610
|
self._report_error("sidecar.send", exc)
|
|
@@ -5605,8 +5632,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5605
5632
|
|
|
5606
5633
|
def _send_result(**kwargs: Any) -> SendResult:
|
|
5607
5634
|
fields = getattr(SendResult, "__dataclass_fields__", {})
|
|
5608
|
-
|
|
5609
|
-
kwargs
|
|
5635
|
+
for optional in ("error_kind", "retry_after"):
|
|
5636
|
+
if optional in kwargs and optional not in fields:
|
|
5637
|
+
kwargs.pop(optional)
|
|
5610
5638
|
return SendResult(**kwargs)
|
|
5611
5639
|
|
|
5612
5640
|
|
package/plugin/inline/cli.py
CHANGED
|
@@ -27,7 +27,10 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
|
|
|
27
27
|
_MAX_TOKEN_BYTES = 16 * 1024
|
|
28
28
|
_MAX_PROBE_RESPONSE_BYTES = 64 * 1024
|
|
29
29
|
_MACHINE_SETUP_PROTOCOL_VERSION = 1
|
|
30
|
-
|
|
30
|
+
# Hermes' own tolerance: macOS readings of one process's start time drift about a second
|
|
31
|
+
# after boot-clock adjustments, and 200 is 2 s on every platform's scale.
|
|
32
|
+
_START_TIME_DRIFT_TOLERANCE = 200
|
|
33
|
+
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.0"
|
|
31
34
|
_ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
32
35
|
|
|
33
36
|
|
|
@@ -581,7 +584,11 @@ def _gateway_status() -> dict:
|
|
|
581
584
|
return {"supported": False, "ready": False, "reason": "runtime_writer_identity_unavailable"}
|
|
582
585
|
# Runtime status can retain platform entries from the prior process.
|
|
583
586
|
# Only the current process's own connected projection is readiness proof.
|
|
584
|
-
|
|
587
|
+
writer_start_time = inline.get("writer_start_time") if isinstance(inline, dict) else None
|
|
588
|
+
writer_matches = (
|
|
589
|
+
isinstance(inline, dict) and inline.get("writer_pid") == pid and type(writer_start_time) is int
|
|
590
|
+
and abs(writer_start_time - start_time) <= _START_TIME_DRIFT_TOLERANCE
|
|
591
|
+
)
|
|
585
592
|
ready = generation is not None and writer_matches and state == "running" and platform_state == "connected"
|
|
586
593
|
return {
|
|
587
594
|
"supported": True,
|