agentscope-runtime 1.0.5.post1__py3-none-any.whl → 1.1.0b2__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.
- agentscope_runtime/__init__.py +3 -0
- agentscope_runtime/adapters/agentscope/message.py +36 -295
- agentscope_runtime/adapters/agentscope/stream.py +89 -2
- agentscope_runtime/adapters/agno/message.py +11 -2
- agentscope_runtime/adapters/agno/stream.py +1 -0
- agentscope_runtime/adapters/langgraph/__init__.py +1 -3
- agentscope_runtime/adapters/langgraph/message.py +11 -106
- agentscope_runtime/adapters/langgraph/stream.py +1 -0
- agentscope_runtime/adapters/ms_agent_framework/message.py +11 -1
- agentscope_runtime/adapters/ms_agent_framework/stream.py +1 -0
- agentscope_runtime/adapters/text/stream.py +1 -0
- agentscope_runtime/common/container_clients/agentrun_client.py +0 -3
- agentscope_runtime/common/container_clients/boxlite_client.py +26 -15
- agentscope_runtime/common/container_clients/fc_client.py +0 -11
- agentscope_runtime/common/utils/deprecation.py +14 -17
- agentscope_runtime/common/utils/logging.py +44 -0
- agentscope_runtime/engine/app/agent_app.py +5 -5
- agentscope_runtime/engine/app/celery_mixin.py +43 -4
- agentscope_runtime/engine/deployers/adapter/agui/__init__.py +8 -1
- agentscope_runtime/engine/deployers/adapter/agui/agui_adapter_utils.py +6 -1
- agentscope_runtime/engine/deployers/adapter/agui/agui_protocol_adapter.py +2 -2
- agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py +13 -0
- agentscope_runtime/engine/runner.py +31 -6
- agentscope_runtime/engine/schemas/agent_schemas.py +28 -0
- agentscope_runtime/engine/services/sandbox/sandbox_service.py +41 -9
- agentscope_runtime/sandbox/box/base/base_sandbox.py +4 -0
- agentscope_runtime/sandbox/box/browser/browser_sandbox.py +4 -0
- agentscope_runtime/sandbox/box/dummy/dummy_sandbox.py +9 -2
- agentscope_runtime/sandbox/box/filesystem/filesystem_sandbox.py +4 -0
- agentscope_runtime/sandbox/box/gui/gui_sandbox.py +5 -1
- agentscope_runtime/sandbox/box/mobile/mobile_sandbox.py +4 -0
- agentscope_runtime/sandbox/box/sandbox.py +122 -13
- agentscope_runtime/sandbox/client/async_http_client.py +1 -0
- agentscope_runtime/sandbox/client/base.py +0 -1
- agentscope_runtime/sandbox/client/http_client.py +0 -2
- agentscope_runtime/sandbox/manager/heartbeat_mixin.py +486 -0
- agentscope_runtime/sandbox/manager/sandbox_manager.py +740 -153
- agentscope_runtime/sandbox/manager/server/app.py +18 -11
- agentscope_runtime/sandbox/manager/server/config.py +10 -2
- agentscope_runtime/sandbox/mcp_server.py +0 -1
- agentscope_runtime/sandbox/model/__init__.py +2 -1
- agentscope_runtime/sandbox/model/container.py +90 -3
- agentscope_runtime/sandbox/model/manager_config.py +45 -1
- agentscope_runtime/version.py +1 -1
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/METADATA +36 -54
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/RECORD +50 -69
- agentscope_runtime/adapters/agentscope/long_term_memory/__init__.py +0 -6
- agentscope_runtime/adapters/agentscope/long_term_memory/_long_term_memory_adapter.py +0 -258
- agentscope_runtime/adapters/agentscope/memory/__init__.py +0 -6
- agentscope_runtime/adapters/agentscope/memory/_memory_adapter.py +0 -152
- agentscope_runtime/engine/services/agent_state/__init__.py +0 -25
- agentscope_runtime/engine/services/agent_state/redis_state_service.py +0 -166
- agentscope_runtime/engine/services/agent_state/state_service.py +0 -179
- agentscope_runtime/engine/services/agent_state/state_service_factory.py +0 -52
- agentscope_runtime/engine/services/memory/__init__.py +0 -33
- agentscope_runtime/engine/services/memory/mem0_memory_service.py +0 -128
- agentscope_runtime/engine/services/memory/memory_service.py +0 -292
- agentscope_runtime/engine/services/memory/memory_service_factory.py +0 -126
- agentscope_runtime/engine/services/memory/redis_memory_service.py +0 -290
- agentscope_runtime/engine/services/memory/reme_personal_memory_service.py +0 -109
- agentscope_runtime/engine/services/memory/reme_task_memory_service.py +0 -11
- agentscope_runtime/engine/services/memory/tablestore_memory_service.py +0 -301
- agentscope_runtime/engine/services/session_history/__init__.py +0 -32
- agentscope_runtime/engine/services/session_history/redis_session_history_service.py +0 -283
- agentscope_runtime/engine/services/session_history/session_history_service.py +0 -267
- agentscope_runtime/engine/services/session_history/session_history_service_factory.py +0 -73
- agentscope_runtime/engine/services/session_history/tablestore_session_history_service.py +0 -288
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/WHEEL +0 -0
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/entry_points.txt +0 -0
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/licenses/LICENSE +0 -0
- {agentscope_runtime-1.0.5.post1.dist-info → agentscope_runtime-1.1.0b2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import asyncio
|
|
3
|
+
import inspect
|
|
4
|
+
import time
|
|
5
|
+
import secrets
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from functools import wraps
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from redis.exceptions import ResponseError
|
|
11
|
+
|
|
12
|
+
from ..model import ContainerModel, ContainerState
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def touch_session(identity_arg: str = "identity"):
|
|
18
|
+
"""Decorator factory that updates session heartbeat derived from identity.
|
|
19
|
+
|
|
20
|
+
This decorator extracts ``identity`` (or the argument named by
|
|
21
|
+
``identity_arg``) from the wrapped function call, resolves
|
|
22
|
+
``session_ctx_id``, updates heartbeat, and triggers restore when needed.
|
|
23
|
+
|
|
24
|
+
.. important:: Any exceptions raised during the touch process are ignored.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
identity_arg (`str`):
|
|
28
|
+
The keyword/parameter name that carries the identity.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
`callable`:
|
|
32
|
+
A decorator that wraps the target function (sync or async).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def decorator(func):
|
|
36
|
+
if asyncio.iscoroutinefunction(func):
|
|
37
|
+
|
|
38
|
+
@wraps(func)
|
|
39
|
+
async def async_wrapper(self, *args, **kwargs):
|
|
40
|
+
try:
|
|
41
|
+
bound = inspect.signature(func).bind_partial(
|
|
42
|
+
self,
|
|
43
|
+
*args,
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
identity = bound.arguments.get(identity_arg)
|
|
47
|
+
if identity is not None:
|
|
48
|
+
session_ctx_id = self.get_session_ctx_id_by_identity(
|
|
49
|
+
identity,
|
|
50
|
+
)
|
|
51
|
+
if session_ctx_id:
|
|
52
|
+
self.update_heartbeat(session_ctx_id)
|
|
53
|
+
if self.needs_restore(session_ctx_id):
|
|
54
|
+
if hasattr(self, "restore_session"):
|
|
55
|
+
self.restore_session(session_ctx_id)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.debug(f"touch_session failed (ignored): {e}")
|
|
58
|
+
|
|
59
|
+
return await func(self, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
return async_wrapper
|
|
62
|
+
|
|
63
|
+
@wraps(func)
|
|
64
|
+
def sync_wrapper(self, *args, **kwargs):
|
|
65
|
+
try:
|
|
66
|
+
bound = inspect.signature(func).bind_partial(
|
|
67
|
+
self,
|
|
68
|
+
*args,
|
|
69
|
+
**kwargs,
|
|
70
|
+
)
|
|
71
|
+
identity = bound.arguments.get(identity_arg)
|
|
72
|
+
if identity is not None:
|
|
73
|
+
session_ctx_id = self.get_session_ctx_id_by_identity(
|
|
74
|
+
identity,
|
|
75
|
+
)
|
|
76
|
+
if session_ctx_id:
|
|
77
|
+
self.update_heartbeat(session_ctx_id)
|
|
78
|
+
if self.needs_restore(session_ctx_id):
|
|
79
|
+
if hasattr(self, "restore_session"):
|
|
80
|
+
self.restore_session(session_ctx_id)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.debug(f"touch_session failed (ignored): {e}")
|
|
83
|
+
|
|
84
|
+
return func(self, *args, **kwargs)
|
|
85
|
+
|
|
86
|
+
return sync_wrapper
|
|
87
|
+
|
|
88
|
+
return decorator
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HeartbeatMixin:
|
|
92
|
+
"""Mixin that provides heartbeat, recycle markers, and a distributed lock.
|
|
93
|
+
|
|
94
|
+
This mixin stores heartbeat timestamps and recycle markers in
|
|
95
|
+
``ContainerModel`` records persisted through ``container_mapping``.
|
|
96
|
+
It also supports a Redis-based distributed lock for reaping/heartbeat
|
|
97
|
+
operations.
|
|
98
|
+
|
|
99
|
+
.. important:: The host class must provide required attributes/methods.
|
|
100
|
+
|
|
101
|
+
Host class requirements:
|
|
102
|
+
- ``self.container_mapping`` (Mapping-like with set/get/delete/scan)
|
|
103
|
+
- ``self.session_mapping`` (Mapping-like with set/get/delete/scan)
|
|
104
|
+
- ``self.get_info(identity) -> dict`` compatible with
|
|
105
|
+
``ContainerModel(**dict)``
|
|
106
|
+
- ``self.config.redis_enabled`` (`bool`)
|
|
107
|
+
- ``self.config.heartbeat_lock_ttl`` (`int`)
|
|
108
|
+
- ``self.redis_client`` (redis client or ``None``)
|
|
109
|
+
- ``self.restore_session(session_ctx_id)`` (optional, for restore)
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
_REDIS_RELEASE_LOCK_LUA = """if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
114
|
+
return redis.call("DEL", KEYS[1])
|
|
115
|
+
else
|
|
116
|
+
return 0
|
|
117
|
+
end
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def _list_container_names_by_session(
|
|
121
|
+
self,
|
|
122
|
+
session_ctx_id: str,
|
|
123
|
+
) -> List[str]:
|
|
124
|
+
"""List container names bound to the given session context id.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
session_ctx_id (`str`):
|
|
128
|
+
The session context id.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
`List[str]`:
|
|
132
|
+
A list of container names for the session, or an empty list.
|
|
133
|
+
"""
|
|
134
|
+
if not session_ctx_id:
|
|
135
|
+
return []
|
|
136
|
+
# session_mapping stores container_name list
|
|
137
|
+
try:
|
|
138
|
+
return self.session_mapping.get(session_ctx_id) or []
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.warning(
|
|
141
|
+
f"_list_container_names_by_session "
|
|
142
|
+
f"failed for session_ctx_id={session_ctx_id}: {e}",
|
|
143
|
+
exc_info=True,
|
|
144
|
+
)
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
def _load_container_model(self, identity: str) -> Optional[ContainerModel]:
|
|
148
|
+
"""Load a `ContainerModel` from storage by container identity.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
identity (`str`):
|
|
152
|
+
The container identity (typically container name).
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
`Optional[ContainerModel]`:
|
|
156
|
+
The loaded model, or ``None`` if it cannot be loaded.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
info_dict = self.get_info(identity)
|
|
160
|
+
return ContainerModel(**info_dict)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.debug(f"_load_container_model failed for {identity}: {e}")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def _save_container_model(self, model: ContainerModel) -> None:
|
|
166
|
+
"""Persist a `ContainerModel` back into ``container_mapping``.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
model (`ContainerModel`):
|
|
170
|
+
The model to persist.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
`None`:
|
|
174
|
+
No return value.
|
|
175
|
+
"""
|
|
176
|
+
# IMPORTANT: persist back into container_mapping
|
|
177
|
+
self.container_mapping.set(model.container_name, model.model_dump())
|
|
178
|
+
|
|
179
|
+
# ---------- heartbeat ----------
|
|
180
|
+
def update_heartbeat(
|
|
181
|
+
self,
|
|
182
|
+
session_ctx_id: str,
|
|
183
|
+
ts: Optional[float] = None,
|
|
184
|
+
) -> float:
|
|
185
|
+
"""Update heartbeat timestamp for all RUNNING containers of a session.
|
|
186
|
+
|
|
187
|
+
The timestamp is written into ``ContainerModel.last_active_at`` and
|
|
188
|
+
``updated_at`` is refreshed.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
session_ctx_id (`str`):
|
|
192
|
+
The session context id.
|
|
193
|
+
ts (`Optional[float]`, optional):
|
|
194
|
+
The timestamp to write. If ``None``, uses ``time.time()``.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
`float`:
|
|
198
|
+
The timestamp that was written.
|
|
199
|
+
"""
|
|
200
|
+
if not session_ctx_id:
|
|
201
|
+
raise ValueError("session_ctx_id is required")
|
|
202
|
+
|
|
203
|
+
ts = float(ts if ts is not None else time.time())
|
|
204
|
+
now = time.time()
|
|
205
|
+
|
|
206
|
+
container_names = self._list_container_names_by_session(session_ctx_id)
|
|
207
|
+
for cname in list(container_names):
|
|
208
|
+
model = self._load_container_model(cname)
|
|
209
|
+
if not model:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
# only update heartbeat for RUNNING containers
|
|
213
|
+
if model.state != ContainerState.RUNNING:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
model.last_active_at = ts
|
|
217
|
+
model.updated_at = now
|
|
218
|
+
|
|
219
|
+
# keep session_ctx_id consistent (migration safety)
|
|
220
|
+
model.session_ctx_id = session_ctx_id
|
|
221
|
+
|
|
222
|
+
self._save_container_model(model)
|
|
223
|
+
|
|
224
|
+
return ts
|
|
225
|
+
|
|
226
|
+
def get_heartbeat(self, session_ctx_id: str) -> Optional[float]:
|
|
227
|
+
"""Get session-level heartbeat as max(last_active_at) of RUNNING items.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
session_ctx_id (`str`):
|
|
231
|
+
The session context id.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
`Optional[float]`:
|
|
235
|
+
The maximum heartbeat timestamp, or ``None`` if unavailable.
|
|
236
|
+
"""
|
|
237
|
+
if not session_ctx_id:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
container_names = self._list_container_names_by_session(session_ctx_id)
|
|
241
|
+
last_vals = []
|
|
242
|
+
for cname in list(container_names):
|
|
243
|
+
model = self._load_container_model(cname)
|
|
244
|
+
if not model:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
if model.state != ContainerState.RUNNING:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if model.last_active_at is not None:
|
|
251
|
+
last_vals.append(float(model.last_active_at))
|
|
252
|
+
|
|
253
|
+
return max(last_vals) if last_vals else None
|
|
254
|
+
|
|
255
|
+
# ---------- recycled marker ----------
|
|
256
|
+
def mark_session_recycled(
|
|
257
|
+
self,
|
|
258
|
+
session_ctx_id: str,
|
|
259
|
+
ts: Optional[float] = None,
|
|
260
|
+
reason: str = "heartbeat_timeout",
|
|
261
|
+
) -> float:
|
|
262
|
+
"""Mark all containers of a session as recycled.
|
|
263
|
+
|
|
264
|
+
This only updates stored metadata; it does not stop/remove containers.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
session_ctx_id (`str`):
|
|
268
|
+
The session context id.
|
|
269
|
+
ts (`Optional[float]`, optional):
|
|
270
|
+
The recycle timestamp. If ``None``, uses ``time.time()``.
|
|
271
|
+
reason (`str`):
|
|
272
|
+
The recycle reason.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
`float`:
|
|
276
|
+
The timestamp that was written.
|
|
277
|
+
"""
|
|
278
|
+
if not session_ctx_id:
|
|
279
|
+
raise ValueError("session_ctx_id is required")
|
|
280
|
+
|
|
281
|
+
ts = float(ts if ts is not None else time.time())
|
|
282
|
+
now = time.time()
|
|
283
|
+
|
|
284
|
+
container_names = self._list_container_names_by_session(session_ctx_id)
|
|
285
|
+
for cname in list(container_names):
|
|
286
|
+
model = self._load_container_model(cname)
|
|
287
|
+
if not model:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# if already released, don't flip back
|
|
291
|
+
if model.state == ContainerState.RELEASED:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
model.state = ContainerState.RECYCLED
|
|
295
|
+
model.recycled_at = ts
|
|
296
|
+
model.recycle_reason = reason
|
|
297
|
+
model.updated_at = now
|
|
298
|
+
|
|
299
|
+
model.session_ctx_id = session_ctx_id
|
|
300
|
+
self._save_container_model(model)
|
|
301
|
+
|
|
302
|
+
return ts
|
|
303
|
+
|
|
304
|
+
def clear_container_recycle_marker(
|
|
305
|
+
self,
|
|
306
|
+
identity: str,
|
|
307
|
+
*,
|
|
308
|
+
set_state: Optional[ContainerState] = None,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Clear recycle marker for a single container and set its state.
|
|
311
|
+
|
|
312
|
+
This resets:
|
|
313
|
+
- ``recycled_at`` to ``None``
|
|
314
|
+
- ``recycle_reason`` to ``None``
|
|
315
|
+
|
|
316
|
+
.. important:: This only updates the stored record; it does not manage
|
|
317
|
+
real container lifecycle and session mapping.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
identity (`str`):
|
|
321
|
+
The container identity.
|
|
322
|
+
set_state (`ContainerState`):
|
|
323
|
+
The state to set on the container record.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
`None`:
|
|
327
|
+
No return value.
|
|
328
|
+
"""
|
|
329
|
+
model = self._load_container_model(identity)
|
|
330
|
+
if not model:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
model.recycled_at = None
|
|
334
|
+
model.recycle_reason = None
|
|
335
|
+
if set_state:
|
|
336
|
+
model.state = set_state
|
|
337
|
+
|
|
338
|
+
model.updated_at = time.time()
|
|
339
|
+
self._save_container_model(model)
|
|
340
|
+
|
|
341
|
+
def needs_restore(self, session_ctx_id: str) -> bool:
|
|
342
|
+
"""Check whether any container in the session is marked for restore.
|
|
343
|
+
|
|
344
|
+
A session is considered needing restore if any bound container is in
|
|
345
|
+
``ContainerState.RECYCLED`` or has ``recycled_at`` set.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
session_ctx_id (`str`):
|
|
349
|
+
The session context id.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
`bool`:
|
|
353
|
+
``True`` if restore is needed, otherwise ``False``.
|
|
354
|
+
"""
|
|
355
|
+
if not session_ctx_id:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
container_names = self._list_container_names_by_session(session_ctx_id)
|
|
359
|
+
for cname in list(container_names):
|
|
360
|
+
model = self._load_container_model(cname)
|
|
361
|
+
if not model:
|
|
362
|
+
continue
|
|
363
|
+
if (
|
|
364
|
+
model.state == ContainerState.RECYCLED
|
|
365
|
+
or model.recycled_at is not None
|
|
366
|
+
):
|
|
367
|
+
return True
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
# ---------- helpers ----------
|
|
371
|
+
def get_session_ctx_id_by_identity(self, identity: str) -> Optional[str]:
|
|
372
|
+
"""Resolve ``session_ctx_id`` from a container identity.
|
|
373
|
+
|
|
374
|
+
It prefers the top-level ``session_ctx_id`` field on `ContainerModel`,
|
|
375
|
+
and falls back to ``meta['session_ctx_id']`` for older payloads.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
identity (`str`):
|
|
379
|
+
The container identity.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
`Optional[str]`:
|
|
383
|
+
The resolved session context id, or ``None`` if not found.
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
info_dict = self.get_info(identity)
|
|
387
|
+
except RuntimeError as exc:
|
|
388
|
+
logger.debug(
|
|
389
|
+
f"get_session_ctx_id_by_identity: container not found for "
|
|
390
|
+
f"identity {identity}: {exc}",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
info = ContainerModel(**info_dict)
|
|
396
|
+
|
|
397
|
+
# NEW: prefer top-level field
|
|
398
|
+
if info.session_ctx_id:
|
|
399
|
+
return info.session_ctx_id
|
|
400
|
+
|
|
401
|
+
# fallback for older payloads
|
|
402
|
+
return (info.meta or {}).get("session_ctx_id")
|
|
403
|
+
|
|
404
|
+
# ---------- redis distributed lock ----------
|
|
405
|
+
def _heartbeat_lock_key(self, session_ctx_id: str) -> str:
|
|
406
|
+
"""Build the Redis key used for heartbeat locking.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
session_ctx_id (`str`):
|
|
410
|
+
The session context id.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
`str`:
|
|
414
|
+
The redis lock key.
|
|
415
|
+
"""
|
|
416
|
+
return f"heartbeat_lock:{session_ctx_id}"
|
|
417
|
+
|
|
418
|
+
def acquire_heartbeat_lock(self, session_ctx_id: str) -> Optional[str]:
|
|
419
|
+
"""Acquire a heartbeat lock for a session.
|
|
420
|
+
|
|
421
|
+
In Redis mode, it uses ``SET key token NX EX ttl``.
|
|
422
|
+
In non-Redis mode, it returns a fixed token ``"inmemory"``.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
session_ctx_id (`str`):
|
|
426
|
+
The session context id.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
`Optional[str]`:
|
|
430
|
+
The lock token if acquired, otherwise ``None``.
|
|
431
|
+
"""
|
|
432
|
+
if not self.config.redis_enabled or self.redis_client is None:
|
|
433
|
+
return "inmemory"
|
|
434
|
+
|
|
435
|
+
key = self._heartbeat_lock_key(session_ctx_id)
|
|
436
|
+
token = secrets.token_hex(16)
|
|
437
|
+
ok = self.redis_client.set(
|
|
438
|
+
key,
|
|
439
|
+
token,
|
|
440
|
+
nx=True,
|
|
441
|
+
ex=int(self.config.heartbeat_lock_ttl),
|
|
442
|
+
)
|
|
443
|
+
return token if ok else None
|
|
444
|
+
|
|
445
|
+
def release_heartbeat_lock(self, session_ctx_id: str, token: str) -> bool:
|
|
446
|
+
"""Release a heartbeat lock if the token matches.
|
|
447
|
+
|
|
448
|
+
It uses a Lua script to ensure only the owner token can release the
|
|
449
|
+
lock.
|
|
450
|
+
If Redis does not support ``EVAL``, it falls back to a GET+DEL check.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
session_ctx_id (`str`):
|
|
454
|
+
The session context id.
|
|
455
|
+
token (`str`):
|
|
456
|
+
The lock token returned by `acquire_heartbeat_lock`.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
`bool`:
|
|
460
|
+
``True`` if the lock was released (or non-Redis mode), else
|
|
461
|
+
``False``.
|
|
462
|
+
"""
|
|
463
|
+
if not self.config.redis_enabled or self.redis_client is None:
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
key = self._heartbeat_lock_key(session_ctx_id)
|
|
467
|
+
try:
|
|
468
|
+
res = self.redis_client.eval(
|
|
469
|
+
self._REDIS_RELEASE_LOCK_LUA,
|
|
470
|
+
1,
|
|
471
|
+
key,
|
|
472
|
+
token,
|
|
473
|
+
)
|
|
474
|
+
return bool(res)
|
|
475
|
+
except ResponseError as e:
|
|
476
|
+
msg = str(e).lower()
|
|
477
|
+
if "unknown command" in msg and "eval" in msg:
|
|
478
|
+
val = self.redis_client.get(key)
|
|
479
|
+
if val == token:
|
|
480
|
+
return bool(self.redis_client.delete(key))
|
|
481
|
+
return False
|
|
482
|
+
logger.warning(f"Failed to release heartbeat lock {key}: {e}")
|
|
483
|
+
raise
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.warning(f"Failed to release heartbeat lock {key}: {e}")
|
|
486
|
+
return False
|