by-framework 0.2.2.dev2__py3-none-any.whl → 0.2.2.dev4__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.
- by_framework/client/client.py +15 -7
- by_framework/core/__init__.py +69 -0
- by_framework/core/availability.py +495 -0
- by_framework/core/delivery_gate.py +60 -0
- by_framework/core/discovery.py +359 -0
- by_framework/core/extensions/__init__.py +29 -0
- by_framework/core/extensions/agent_config.py +64 -0
- by_framework/core/extensions/plugin.py +312 -0
- by_framework/core/extensions/registry.py +704 -0
- by_framework/core/extensions/trace_provider.py +20 -0
- by_framework/core/protocol/__init__.py +99 -0
- by_framework/core/protocol/action_type.py +33 -0
- by_framework/core/protocol/agent_state.py +78 -0
- by_framework/core/protocol/byai_codec.py +96 -0
- by_framework/core/protocol/byai_command.py +53 -0
- by_framework/core/protocol/byai_types.py +7 -0
- by_framework/core/protocol/commands.py +285 -0
- by_framework/core/protocol/content_codec.py +17 -0
- by_framework/core/protocol/content_type.py +38 -0
- by_framework/core/protocol/data_message.py +45 -0
- by_framework/core/protocol/data_shapes.py +83 -0
- by_framework/core/protocol/event_type.py +34 -0
- by_framework/core/protocol/events.py +69 -0
- by_framework/core/protocol/message.py +99 -0
- by_framework/core/protocol/message_header.py +78 -0
- by_framework/core/protocol/responses.py +94 -0
- by_framework/core/protocol/results.py +149 -0
- by_framework/core/registry.py +1102 -0
- by_framework/core/runtime/__init__.py +27 -0
- by_framework/core/runtime/agent_config_manager.py +283 -0
- by_framework/core/runtime/agent_runtime_state.py +75 -0
- by_framework/core/runtime/file_manager.py +434 -0
- by_framework/core/runtime/file_paths.py +76 -0
- by_framework/core/runtime/file_permissions.py +71 -0
- by_framework/core/runtime/filestore/__init__.py +15 -0
- by_framework/core/runtime/filestore/base.py +140 -0
- by_framework/core/runtime/filestore/local.py +313 -0
- by_framework/core/runtime/history/__init__.py +10 -0
- by_framework/core/runtime/history/base.py +57 -0
- by_framework/core/runtime/history/history_manager.py +55 -0
- by_framework/core/runtime/history/in_memory.py +58 -0
- by_framework/core/runtime/session_manager.py +118 -0
- by_framework/core/wakeup_controller.py +149 -0
- by_framework/core/workspace.py +126 -0
- {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/METADATA +1 -1
- by_framework-0.2.2.dev4.dist-info/RECORD +92 -0
- by_framework-0.2.2.dev2.dist-info/RECORD +0 -49
- {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/WHEEL +0 -0
- {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Worker registry module.
|
|
3
|
+
|
|
4
|
+
Provides worker registration, discovery, and execution tracking
|
|
5
|
+
through Redis-backed storage.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
import warnings
|
|
15
|
+
from typing import Any, List, Optional, TypedDict
|
|
16
|
+
|
|
17
|
+
from by_framework.common.constants import (EXEC_FIELD_PREFIX, MSG_MAP_PREFIX,
|
|
18
|
+
RedisKeys)
|
|
19
|
+
from by_framework.common.exceptions import ExecutionDataError
|
|
20
|
+
from by_framework.common.redis_client import Redis, get_redis
|
|
21
|
+
from by_framework.core.extensions import AgentConfigsSnapshot, PluginRegistry
|
|
22
|
+
from by_framework.core.protocol.agent_state import is_terminal_state
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("by_framework.registry")
|
|
25
|
+
SNAPSHOT_PAYLOAD_PREFIX = "dill-base64:"
|
|
26
|
+
PRESENCE_PAYLOAD_VERSION = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExecutionCompletionFields(TypedDict, total=False):
|
|
30
|
+
"""Structured terminal metadata attached for observability."""
|
|
31
|
+
|
|
32
|
+
error_type: str
|
|
33
|
+
error_message: str
|
|
34
|
+
error_code: str
|
|
35
|
+
failed_stage: str
|
|
36
|
+
retryable: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decode_worker_presence(raw: Any) -> tuple[Optional[str], int, bool]:
|
|
40
|
+
"""Decode worker presence payload.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
(owner token, last_seen timestamp in ms, whether the payload is legacy)
|
|
44
|
+
"""
|
|
45
|
+
if raw is None:
|
|
46
|
+
return (None, 0, False)
|
|
47
|
+
if isinstance(raw, bytes):
|
|
48
|
+
raw = raw.decode("utf-8")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(raw)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return (str(raw), 0, True)
|
|
54
|
+
|
|
55
|
+
if isinstance(payload, dict):
|
|
56
|
+
token = payload.get("token")
|
|
57
|
+
last_seen = payload.get("last_seen", 0)
|
|
58
|
+
if token is not None:
|
|
59
|
+
token = str(token)
|
|
60
|
+
return (token, int(last_seen or 0), False)
|
|
61
|
+
|
|
62
|
+
if payload == 1:
|
|
63
|
+
return (None, 0, True)
|
|
64
|
+
return (str(payload), 0, True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _encode_worker_presence(token: Optional[str], last_seen: int) -> str:
|
|
68
|
+
return json.dumps(
|
|
69
|
+
{
|
|
70
|
+
"version": PRESENCE_PAYLOAD_VERSION,
|
|
71
|
+
"token": token,
|
|
72
|
+
"last_seen": last_seen,
|
|
73
|
+
},
|
|
74
|
+
separators=(",", ":"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- Standalone agent type probing functions (usable without WorkerRegistry) ---
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def check_worker_online(
|
|
82
|
+
redis: Redis,
|
|
83
|
+
worker_id: str,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Check if the specified worker is active.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
redis: Redis client instance.
|
|
89
|
+
worker_id: Worker ID.
|
|
90
|
+
Returns:
|
|
91
|
+
Whether the worker is active.
|
|
92
|
+
"""
|
|
93
|
+
lease_value = await redis.get(RedisKeys.worker_online_lease(worker_id))
|
|
94
|
+
if lease_value is None:
|
|
95
|
+
return False
|
|
96
|
+
decoded_token, last_seen, is_legacy = _decode_worker_presence(lease_value)
|
|
97
|
+
del decoded_token
|
|
98
|
+
return is_legacy or last_seen > 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def check_agent_type_online(
|
|
102
|
+
redis: Redis,
|
|
103
|
+
agent_type: str,
|
|
104
|
+
check_active: bool = True,
|
|
105
|
+
) -> tuple[bool, List[str]]:
|
|
106
|
+
"""Check if there are registered and active workers for the agent type.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
redis: Redis client instance.
|
|
110
|
+
agent_type: Agent type identifier.
|
|
111
|
+
check_active: Whether to check worker active status (default True).
|
|
112
|
+
Returns:
|
|
113
|
+
(Whether there are active workers, list of active worker IDs)
|
|
114
|
+
"""
|
|
115
|
+
workers = await redis.smembers(RedisKeys.agent_type_members(agent_type))
|
|
116
|
+
if not workers:
|
|
117
|
+
return (False, [])
|
|
118
|
+
|
|
119
|
+
worker_ids = [w.decode() if isinstance(w, bytes) else w for w in workers]
|
|
120
|
+
|
|
121
|
+
if check_active:
|
|
122
|
+
online_worker_ids = []
|
|
123
|
+
for worker_id in worker_ids:
|
|
124
|
+
if await check_worker_online(redis, worker_id):
|
|
125
|
+
online_worker_ids.append(worker_id)
|
|
126
|
+
worker_ids = online_worker_ids
|
|
127
|
+
|
|
128
|
+
return (len(worker_ids) > 0, worker_ids)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class WorkerRegistry:
|
|
132
|
+
"""Worker registry responsible for worker registration, discovery, and execution.
|
|
133
|
+
|
|
134
|
+
Stores worker information and execution state through Redis sorted sets
|
|
135
|
+
and Hash structures.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, redis_client: Optional[Redis] = None):
|
|
139
|
+
self.redis = redis_client or get_redis()
|
|
140
|
+
self._lock_tokens: dict[str, str] = {}
|
|
141
|
+
|
|
142
|
+
async def register_worker_membership(
|
|
143
|
+
self, worker_id: str, agent_types: List[str]
|
|
144
|
+
) -> None:
|
|
145
|
+
await self.redis.sadd(RedisKeys.KNOWN_WORKERS, worker_id)
|
|
146
|
+
for agent_type in agent_types:
|
|
147
|
+
await self.redis.sadd(
|
|
148
|
+
RedisKeys.worker_declared_agent_types(worker_id), agent_type
|
|
149
|
+
)
|
|
150
|
+
await self.redis.sadd(RedisKeys.agent_type_members(agent_type), worker_id)
|
|
151
|
+
|
|
152
|
+
async def heartbeat_worker(
|
|
153
|
+
self,
|
|
154
|
+
worker_id: str,
|
|
155
|
+
lease_ttl_seconds: int = RedisKeys.WORKER_DEFAULT_LEASE_TTL_SECONDS,
|
|
156
|
+
) -> bool:
|
|
157
|
+
now = int(time.time() * 1000)
|
|
158
|
+
lease_key = RedisKeys.worker_online_lease(worker_id)
|
|
159
|
+
token = self._lock_tokens.get(worker_id)
|
|
160
|
+
current = await self.redis.get(lease_key)
|
|
161
|
+
current_token, decoded_last_seen, is_legacy = _decode_worker_presence(current)
|
|
162
|
+
del decoded_last_seen, is_legacy
|
|
163
|
+
|
|
164
|
+
if token:
|
|
165
|
+
if current is None:
|
|
166
|
+
ok = await self.redis.set(
|
|
167
|
+
lease_key,
|
|
168
|
+
_encode_worker_presence(token, now),
|
|
169
|
+
nx=True,
|
|
170
|
+
ex=lease_ttl_seconds,
|
|
171
|
+
)
|
|
172
|
+
if not ok:
|
|
173
|
+
return False
|
|
174
|
+
elif current_token != token:
|
|
175
|
+
return False
|
|
176
|
+
else:
|
|
177
|
+
await self.redis.set(
|
|
178
|
+
lease_key,
|
|
179
|
+
_encode_worker_presence(token, now),
|
|
180
|
+
ex=lease_ttl_seconds,
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
if current_token is not None:
|
|
184
|
+
return False
|
|
185
|
+
await self.redis.set(
|
|
186
|
+
lease_key,
|
|
187
|
+
_encode_worker_presence(None, now),
|
|
188
|
+
ex=lease_ttl_seconds,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
await self.redis.sadd(RedisKeys.KNOWN_WORKERS, worker_id)
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
async def register_worker(self, worker_id: str, agent_types: List[str]):
|
|
195
|
+
"""Compatibility wrapper for callers that couple registration and heartbeat."""
|
|
196
|
+
warnings.warn(
|
|
197
|
+
"WorkerRegistry.register_worker() is deprecated; use "
|
|
198
|
+
"register_worker_membership() plus heartbeat_worker() instead.",
|
|
199
|
+
DeprecationWarning,
|
|
200
|
+
stacklevel=2,
|
|
201
|
+
)
|
|
202
|
+
await self.register_worker_membership(worker_id, agent_types)
|
|
203
|
+
await self.heartbeat_worker(worker_id)
|
|
204
|
+
|
|
205
|
+
async def unregister_worker_membership(self, worker_id: str) -> None:
|
|
206
|
+
"""Remove static worker-agent-type membership without mutating liveness."""
|
|
207
|
+
agent_types_raw = await self.redis.smembers(
|
|
208
|
+
RedisKeys.worker_declared_agent_types(worker_id)
|
|
209
|
+
)
|
|
210
|
+
await self.redis.delete(RedisKeys.worker_declared_agent_types(worker_id))
|
|
211
|
+
await self.redis.srem(RedisKeys.KNOWN_WORKERS, worker_id)
|
|
212
|
+
for agent_type_raw in agent_types_raw:
|
|
213
|
+
agent_type = (
|
|
214
|
+
agent_type_raw.decode()
|
|
215
|
+
if isinstance(agent_type_raw, bytes)
|
|
216
|
+
else agent_type_raw
|
|
217
|
+
)
|
|
218
|
+
await self.redis.srem(RedisKeys.agent_type_members(agent_type), worker_id)
|
|
219
|
+
|
|
220
|
+
async def mark_worker_inactive(
|
|
221
|
+
self, worker_id: str, token: Optional[str] = None
|
|
222
|
+
) -> bool:
|
|
223
|
+
expected = token or self._lock_tokens.get(worker_id)
|
|
224
|
+
lease_key = RedisKeys.worker_online_lease(worker_id)
|
|
225
|
+
current = await self.redis.get(lease_key)
|
|
226
|
+
current_token, decoded_last_seen, is_legacy = _decode_worker_presence(current)
|
|
227
|
+
del decoded_last_seen, is_legacy
|
|
228
|
+
if expected and current_token != expected:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
await self.redis.delete(lease_key)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
async def unregister_worker(self, worker_id: str):
|
|
235
|
+
"""Compatibility wrapper for callers that couple deregistration and liveness."""
|
|
236
|
+
warnings.warn(
|
|
237
|
+
"WorkerRegistry.unregister_worker() is deprecated; use "
|
|
238
|
+
"mark_worker_inactive() plus unregister_worker_membership() instead.",
|
|
239
|
+
DeprecationWarning,
|
|
240
|
+
stacklevel=2,
|
|
241
|
+
)
|
|
242
|
+
await self.mark_worker_inactive(worker_id)
|
|
243
|
+
await self.unregister_worker_membership(worker_id)
|
|
244
|
+
|
|
245
|
+
async def get_online_workers(
|
|
246
|
+
self,
|
|
247
|
+
agent_type: str,
|
|
248
|
+
) -> List[str]:
|
|
249
|
+
_, worker_ids = await check_agent_type_online(
|
|
250
|
+
self.redis,
|
|
251
|
+
agent_type,
|
|
252
|
+
check_active=True,
|
|
253
|
+
)
|
|
254
|
+
return worker_ids
|
|
255
|
+
|
|
256
|
+
async def get_random_online_worker(
|
|
257
|
+
self,
|
|
258
|
+
agent_type: str,
|
|
259
|
+
) -> Optional[str]:
|
|
260
|
+
workers = await self.get_online_workers(agent_type)
|
|
261
|
+
if not workers:
|
|
262
|
+
return None
|
|
263
|
+
return random.choice(workers)
|
|
264
|
+
|
|
265
|
+
async def get_target_worker(self, agent_id: str) -> Optional[str]:
|
|
266
|
+
return await self.get_random_online_worker(agent_id)
|
|
267
|
+
|
|
268
|
+
async def is_worker_online(
|
|
269
|
+
self,
|
|
270
|
+
worker_id: str,
|
|
271
|
+
) -> bool:
|
|
272
|
+
"""Check if the specified worker is active.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
worker_id: Worker ID.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Whether the worker is active.
|
|
279
|
+
"""
|
|
280
|
+
return await check_worker_online(self.redis, worker_id)
|
|
281
|
+
|
|
282
|
+
async def has_online_agent_type(
|
|
283
|
+
self,
|
|
284
|
+
agent_type: str,
|
|
285
|
+
check_active: bool = True,
|
|
286
|
+
) -> tuple[bool, List[str]]:
|
|
287
|
+
"""Check if there are registered and active workers for the agent type.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
agent_type: Agent type identifier.
|
|
291
|
+
check_active: Whether to check worker active status (default True).
|
|
292
|
+
Returns:
|
|
293
|
+
(Whether there are active workers, list of active worker IDs)
|
|
294
|
+
"""
|
|
295
|
+
return await check_agent_type_online(self.redis, agent_type, check_active)
|
|
296
|
+
|
|
297
|
+
async def get_all_workers(self) -> dict[str, Any]:
|
|
298
|
+
"""Get all active Worker information.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Dictionary containing active worker IDs with their capabilities
|
|
302
|
+
and last active time.
|
|
303
|
+
"""
|
|
304
|
+
redis_inst = self.redis
|
|
305
|
+
worker_ids_raw = await redis_inst.smembers(RedisKeys.KNOWN_WORKERS)
|
|
306
|
+
worker_ids = [w.decode() if isinstance(w, bytes) else w for w in worker_ids_raw]
|
|
307
|
+
|
|
308
|
+
result = {}
|
|
309
|
+
for worker_id in sorted(worker_ids):
|
|
310
|
+
presence = await redis_inst.get(RedisKeys.worker_online_lease(worker_id))
|
|
311
|
+
decoded_token, last_seen, is_legacy = _decode_worker_presence(presence)
|
|
312
|
+
del decoded_token
|
|
313
|
+
if presence is None or (not is_legacy and last_seen <= 0):
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
agent_types_raw = await redis_inst.smembers(
|
|
317
|
+
RedisKeys.worker_declared_agent_types(worker_id)
|
|
318
|
+
)
|
|
319
|
+
agent_types = [
|
|
320
|
+
c.decode() if isinstance(c, bytes) else c for c in agent_types_raw
|
|
321
|
+
]
|
|
322
|
+
result[worker_id] = {
|
|
323
|
+
"agent_types": agent_types,
|
|
324
|
+
"last_seen": int(time.time() * 1000) if is_legacy else last_seen,
|
|
325
|
+
}
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
async def claim_worker_id(self, worker_id: str, ttl_seconds: int = 60) -> str:
|
|
329
|
+
"""Attempt to acquire an exclusive lock for Worker ID.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
worker_id: Worker ID to acquire lock for
|
|
333
|
+
ttl_seconds: Lock TTL in seconds
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Lock token
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: If worker_id is already in use
|
|
340
|
+
"""
|
|
341
|
+
token = uuid.uuid4().hex
|
|
342
|
+
lease_key = RedisKeys.worker_online_lease(worker_id)
|
|
343
|
+
ok = await self.redis.set(
|
|
344
|
+
lease_key,
|
|
345
|
+
_encode_worker_presence(token, 0),
|
|
346
|
+
nx=True,
|
|
347
|
+
ex=ttl_seconds,
|
|
348
|
+
)
|
|
349
|
+
if not ok:
|
|
350
|
+
raise ValueError(f"worker_id already in use: {worker_id}")
|
|
351
|
+
self._lock_tokens[worker_id] = token
|
|
352
|
+
await self.redis.sadd(RedisKeys.KNOWN_WORKERS, worker_id)
|
|
353
|
+
return token
|
|
354
|
+
|
|
355
|
+
async def refresh_worker_id_lock(
|
|
356
|
+
self, worker_id: str, ttl_seconds: int = 60
|
|
357
|
+
) -> bool:
|
|
358
|
+
"""Refresh the TTL of the Worker ID lock.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
worker_id: Worker ID
|
|
362
|
+
ttl_seconds: New TTL in seconds
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True if refresh succeeded, otherwise False
|
|
366
|
+
"""
|
|
367
|
+
token = self._lock_tokens.get(worker_id)
|
|
368
|
+
if not token:
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
lease_key = RedisKeys.worker_online_lease(worker_id)
|
|
372
|
+
current = await self.redis.get(lease_key)
|
|
373
|
+
current_token, decoded_last_seen, is_legacy = _decode_worker_presence(current)
|
|
374
|
+
del decoded_last_seen, is_legacy
|
|
375
|
+
if current_token != token:
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
result = await self.redis.expire(lease_key, ttl_seconds)
|
|
379
|
+
return bool(result)
|
|
380
|
+
|
|
381
|
+
async def release_worker_id(
|
|
382
|
+
self, worker_id: str, token: Optional[str] = None
|
|
383
|
+
) -> bool:
|
|
384
|
+
"""Release the exclusive lock for Worker ID.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
worker_id: Worker ID
|
|
388
|
+
token: Optional lock token
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
True if release succeeded, otherwise False
|
|
392
|
+
"""
|
|
393
|
+
expected = token or self._lock_tokens.get(worker_id)
|
|
394
|
+
if not expected:
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
key = RedisKeys.worker_online_lease(worker_id)
|
|
398
|
+
current = await self.redis.get(key)
|
|
399
|
+
current_token, decoded_last_seen, is_legacy = _decode_worker_presence(current)
|
|
400
|
+
del decoded_last_seen, is_legacy
|
|
401
|
+
if current_token != expected:
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
await self.redis.delete(key)
|
|
405
|
+
self._lock_tokens.pop(worker_id, None)
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
async def initialize_execution(self, execution: dict[str, Any]):
|
|
409
|
+
"""Initialize Execution on sender side (status QUEUED) with first timeline
|
|
410
|
+
record.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
execution: Execution info dict containing execution_id, message_id,
|
|
414
|
+
session_id, etc.
|
|
415
|
+
"""
|
|
416
|
+
now = int(time.time() * 1000)
|
|
417
|
+
execution["created_at"] = now
|
|
418
|
+
execution["updated_at"] = now
|
|
419
|
+
execution.setdefault("started_at", 0)
|
|
420
|
+
execution.setdefault("finished_at", 0)
|
|
421
|
+
if "timeline" not in execution:
|
|
422
|
+
execution["timeline"] = [
|
|
423
|
+
{"status": execution.get("status", "QUEUED"), "timestamp": now}
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
execution_id = execution["execution_id"]
|
|
427
|
+
message_id = execution["message_id"]
|
|
428
|
+
session_id = execution["session_id"]
|
|
429
|
+
|
|
430
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
431
|
+
encoded_data = json.dumps(execution, ensure_ascii=False)
|
|
432
|
+
|
|
433
|
+
# Use Pipeline to ensure atomicity and set TTL
|
|
434
|
+
pipe = self.redis.pipeline()
|
|
435
|
+
pipe.hset(reg_key, f"{EXEC_FIELD_PREFIX}{execution_id}", encoded_data)
|
|
436
|
+
pipe.hset(reg_key, f"{MSG_MAP_PREFIX}{message_id}", execution_id)
|
|
437
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
438
|
+
await pipe.execute()
|
|
439
|
+
|
|
440
|
+
async def persist_agent_configs_snapshot(
|
|
441
|
+
self,
|
|
442
|
+
snapshot_key: str,
|
|
443
|
+
snapshot: AgentConfigsSnapshot,
|
|
444
|
+
) -> str:
|
|
445
|
+
"""Persist an AgentConfigsSnapshot blob for later execution recovery."""
|
|
446
|
+
payload = SNAPSHOT_PAYLOAD_PREFIX + base64.b64encode(
|
|
447
|
+
PluginRegistry.serialize_agent_configs_snapshot(snapshot)
|
|
448
|
+
).decode("ascii")
|
|
449
|
+
redis_key = RedisKeys.agent_configs_snapshot(snapshot_key)
|
|
450
|
+
await self.redis.set(
|
|
451
|
+
redis_key,
|
|
452
|
+
payload,
|
|
453
|
+
ex=RedisKeys.AGENT_CONFIGS_SNAPSHOT_TTL_SECONDS,
|
|
454
|
+
)
|
|
455
|
+
return snapshot_key
|
|
456
|
+
|
|
457
|
+
async def load_agent_configs_snapshot(
|
|
458
|
+
self,
|
|
459
|
+
snapshot_key: str,
|
|
460
|
+
) -> Optional[AgentConfigsSnapshot]:
|
|
461
|
+
"""Load a previously persisted AgentConfigsSnapshot by key."""
|
|
462
|
+
payload = await self.redis.get(RedisKeys.agent_configs_snapshot(snapshot_key))
|
|
463
|
+
if payload is None:
|
|
464
|
+
return None
|
|
465
|
+
if isinstance(payload, str):
|
|
466
|
+
if not payload.startswith(SNAPSHOT_PAYLOAD_PREFIX):
|
|
467
|
+
raise ValueError(
|
|
468
|
+
"Unsupported persisted agent configs snapshot payload format"
|
|
469
|
+
)
|
|
470
|
+
payload = base64.b64decode(payload.removeprefix(SNAPSHOT_PAYLOAD_PREFIX))
|
|
471
|
+
elif isinstance(payload, bytes) and payload.startswith(
|
|
472
|
+
SNAPSHOT_PAYLOAD_PREFIX.encode("ascii")
|
|
473
|
+
):
|
|
474
|
+
payload = base64.b64decode(
|
|
475
|
+
payload.removeprefix(SNAPSHOT_PAYLOAD_PREFIX.encode("ascii"))
|
|
476
|
+
)
|
|
477
|
+
return PluginRegistry.deserialize_agent_configs_snapshot(payload)
|
|
478
|
+
|
|
479
|
+
async def delete_agent_configs_snapshot(self, snapshot_key: str) -> None:
|
|
480
|
+
"""Delete a persisted AgentConfigsSnapshot blob."""
|
|
481
|
+
await self.redis.delete(RedisKeys.agent_configs_snapshot(snapshot_key))
|
|
482
|
+
|
|
483
|
+
def _build_worker_execution_snapshot(
|
|
484
|
+
self, execution: dict[str, Any]
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
"""Build a compact worker-facing execution snapshot."""
|
|
487
|
+
return {
|
|
488
|
+
"execution_id": execution.get("execution_id", ""),
|
|
489
|
+
"message_id": execution.get("message_id", ""),
|
|
490
|
+
"session_id": execution.get("session_id", ""),
|
|
491
|
+
"trace_id": execution.get("trace_id", ""),
|
|
492
|
+
"worker_id": execution.get("worker_id", ""),
|
|
493
|
+
"source_agent_type": execution.get("source_agent_type", ""),
|
|
494
|
+
"target_agent_type": execution.get("target_agent_type", ""),
|
|
495
|
+
"stream_name": execution.get("stream_name", ""),
|
|
496
|
+
"redis_message_id": execution.get("redis_message_id", ""),
|
|
497
|
+
"status": execution.get("status", ""),
|
|
498
|
+
"created_at": int(execution.get("created_at", 0) or 0),
|
|
499
|
+
"started_at": int(execution.get("started_at", 0) or 0),
|
|
500
|
+
"finished_at": int(execution.get("finished_at", 0) or 0),
|
|
501
|
+
"updated_at": int(execution.get("updated_at", 0) or 0),
|
|
502
|
+
"parent_message_id": execution.get("parent_message_id", ""),
|
|
503
|
+
"cancel_requested": bool(execution.get("cancel_requested", False)),
|
|
504
|
+
"cancel_reason": execution.get("cancel_reason", ""),
|
|
505
|
+
"route_policy": execution.get("route_policy", ""),
|
|
506
|
+
"route_status": execution.get("route_status", ""),
|
|
507
|
+
"selected_agent_type": execution.get("selected_agent_type", ""),
|
|
508
|
+
"availability_error_code": execution.get("availability_error_code", ""),
|
|
509
|
+
"availability_error": execution.get("availability_error", ""),
|
|
510
|
+
"error_type": execution.get("error_type", ""),
|
|
511
|
+
"error_message": execution.get("error_message", ""),
|
|
512
|
+
"error_code": execution.get("error_code", ""),
|
|
513
|
+
"failed_stage": execution.get("failed_stage", ""),
|
|
514
|
+
"retryable": bool(execution.get("retryable", False)),
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
@staticmethod
|
|
518
|
+
def _status_count_field(status: str) -> str:
|
|
519
|
+
return f"{status.lower()}_count"
|
|
520
|
+
|
|
521
|
+
async def _update_worker_execution_stats(
|
|
522
|
+
self,
|
|
523
|
+
old_execution: Optional[dict[str, Any]],
|
|
524
|
+
new_execution: dict[str, Any],
|
|
525
|
+
now: int,
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Incrementally update worker-level aggregate stats and snapshots."""
|
|
528
|
+
worker_id = str(new_execution.get("worker_id") or "")
|
|
529
|
+
if not worker_id:
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
old_worker_id = str((old_execution or {}).get("worker_id") or "")
|
|
533
|
+
old_status = str((old_execution or {}).get("status") or "")
|
|
534
|
+
new_status = str(new_execution.get("status") or "")
|
|
535
|
+
execution_id = str(new_execution["execution_id"])
|
|
536
|
+
|
|
537
|
+
first_seen_by_worker = old_worker_id != worker_id
|
|
538
|
+
old_active = bool(
|
|
539
|
+
old_worker_id == worker_id and not is_terminal_state(old_status)
|
|
540
|
+
)
|
|
541
|
+
new_active = not is_terminal_state(new_status)
|
|
542
|
+
|
|
543
|
+
status_key = RedisKeys.worker_status(worker_id)
|
|
544
|
+
history_key = RedisKeys.worker_executions(worker_id)
|
|
545
|
+
active_key = RedisKeys.worker_active_execution_index(worker_id)
|
|
546
|
+
active_snapshots_key = RedisKeys.worker_active_snapshots(worker_id)
|
|
547
|
+
history_snapshots_key = RedisKeys.worker_history_snapshots(worker_id)
|
|
548
|
+
snapshot = json.dumps(
|
|
549
|
+
self._build_worker_execution_snapshot(new_execution), ensure_ascii=False
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
pipe = self.redis.pipeline()
|
|
553
|
+
if first_seen_by_worker:
|
|
554
|
+
pipe.hincrby(status_key, "total_count", 1)
|
|
555
|
+
if first_seen_by_worker or old_status != new_status:
|
|
556
|
+
if old_status and old_worker_id == worker_id:
|
|
557
|
+
pipe.hincrby(status_key, self._status_count_field(old_status), -1)
|
|
558
|
+
if new_status:
|
|
559
|
+
pipe.hincrby(status_key, self._status_count_field(new_status), 1)
|
|
560
|
+
if not old_active and new_active:
|
|
561
|
+
pipe.hincrby(status_key, "active_count", 1)
|
|
562
|
+
elif old_active and not new_active:
|
|
563
|
+
pipe.hincrby(status_key, "active_count", -1)
|
|
564
|
+
|
|
565
|
+
pipe.hset(status_key, "last_updated_at", now)
|
|
566
|
+
if new_status == "RUNNING":
|
|
567
|
+
pipe.hset(
|
|
568
|
+
status_key,
|
|
569
|
+
"last_started_at",
|
|
570
|
+
int(new_execution.get("started_at", 0) or 0),
|
|
571
|
+
)
|
|
572
|
+
if is_terminal_state(new_status):
|
|
573
|
+
pipe.hset(
|
|
574
|
+
status_key,
|
|
575
|
+
"last_finished_at",
|
|
576
|
+
int(new_execution.get("finished_at", 0) or 0),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
pipe.zadd(history_key, {execution_id: now})
|
|
580
|
+
pipe.hset(history_snapshots_key, execution_id, snapshot)
|
|
581
|
+
if new_active:
|
|
582
|
+
pipe.zadd(active_key, {execution_id: now})
|
|
583
|
+
pipe.hset(active_snapshots_key, execution_id, snapshot)
|
|
584
|
+
else:
|
|
585
|
+
pipe.zrem(active_key, execution_id)
|
|
586
|
+
pipe.hdel(active_snapshots_key, execution_id)
|
|
587
|
+
pipe.expire(status_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
588
|
+
pipe.expire(history_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
589
|
+
pipe.expire(active_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
590
|
+
pipe.expire(active_snapshots_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
591
|
+
pipe.expire(history_snapshots_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
592
|
+
await pipe.execute()
|
|
593
|
+
|
|
594
|
+
async def update_execution_status(
|
|
595
|
+
self, execution_id: str, session_id: str, status: str, **kwargs
|
|
596
|
+
):
|
|
597
|
+
"""Update existing execution record's status and append to timeline."""
|
|
598
|
+
current = await self.get_execution(execution_id, session_id)
|
|
599
|
+
if current is None:
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
old_execution = dict(current)
|
|
603
|
+
now = int(time.time() * 1000)
|
|
604
|
+
current["status"] = status
|
|
605
|
+
current["updated_at"] = now
|
|
606
|
+
if status == "RUNNING" and current.get("started_at", 0) == 0:
|
|
607
|
+
current["started_at"] = now
|
|
608
|
+
|
|
609
|
+
for key, value in kwargs.items():
|
|
610
|
+
current[key] = value
|
|
611
|
+
|
|
612
|
+
timeline = current.get("timeline", [])
|
|
613
|
+
timeline.append({"status": status, "timestamp": now})
|
|
614
|
+
current["timeline"] = timeline
|
|
615
|
+
|
|
616
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
617
|
+
pipe = self.redis.pipeline()
|
|
618
|
+
pipe.hset(
|
|
619
|
+
reg_key,
|
|
620
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
621
|
+
json.dumps(current, ensure_ascii=False),
|
|
622
|
+
)
|
|
623
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
624
|
+
await pipe.execute()
|
|
625
|
+
await self._update_worker_execution_stats(old_execution, current, now)
|
|
626
|
+
|
|
627
|
+
async def update_execution_status_by_message(
|
|
628
|
+
self, message_id: str, session_id: str, status: str
|
|
629
|
+
):
|
|
630
|
+
"""Update specific execution status by message_id and append to timeline"""
|
|
631
|
+
execution = await self.get_execution_by_message_id(message_id, session_id)
|
|
632
|
+
if not execution:
|
|
633
|
+
return
|
|
634
|
+
await self.update_execution_status(
|
|
635
|
+
execution["execution_id"], session_id, status
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
async def update_execution_fields(
|
|
639
|
+
self, execution_id: str, session_id: str, **kwargs: Any
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Update execution metadata fields without changing status or timeline."""
|
|
642
|
+
current = await self.get_execution(execution_id, session_id)
|
|
643
|
+
if current is None:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
current.update(kwargs)
|
|
647
|
+
current["updated_at"] = int(time.time() * 1000)
|
|
648
|
+
|
|
649
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
650
|
+
pipe = self.redis.pipeline()
|
|
651
|
+
pipe.hset(
|
|
652
|
+
reg_key,
|
|
653
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
654
|
+
json.dumps(current, ensure_ascii=False),
|
|
655
|
+
)
|
|
656
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
657
|
+
await pipe.execute()
|
|
658
|
+
|
|
659
|
+
async def save_execution(self, execution: dict[str, Any]):
|
|
660
|
+
"""(Compatibility) Save execution data to Redis.
|
|
661
|
+
|
|
662
|
+
Recommend using initialize_execution first.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
execution: Execution info dict containing execution_id,
|
|
666
|
+
message_id, session_id, etc.
|
|
667
|
+
"""
|
|
668
|
+
now = int(time.time() * 1000)
|
|
669
|
+
if "created_at" not in execution or execution["created_at"] == 0:
|
|
670
|
+
execution["created_at"] = now
|
|
671
|
+
if "updated_at" not in execution or execution["updated_at"] == 0:
|
|
672
|
+
execution["updated_at"] = now
|
|
673
|
+
|
|
674
|
+
if "timeline" not in execution:
|
|
675
|
+
execution["timeline"] = [
|
|
676
|
+
{"status": execution.get("status", "RUNNING"), "timestamp": now}
|
|
677
|
+
]
|
|
678
|
+
|
|
679
|
+
execution_id = execution["execution_id"]
|
|
680
|
+
message_id = execution["message_id"]
|
|
681
|
+
session_id = execution["session_id"]
|
|
682
|
+
old_execution = await self.get_execution(execution_id, session_id)
|
|
683
|
+
|
|
684
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
685
|
+
encoded_data = json.dumps(execution, ensure_ascii=False)
|
|
686
|
+
|
|
687
|
+
# Use Pipeline to ensure atomicity and set TTL
|
|
688
|
+
pipe = self.redis.pipeline()
|
|
689
|
+
pipe.hset(reg_key, f"{EXEC_FIELD_PREFIX}{execution_id}", encoded_data)
|
|
690
|
+
pipe.hset(reg_key, f"{MSG_MAP_PREFIX}{message_id}", execution_id)
|
|
691
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
692
|
+
await pipe.execute()
|
|
693
|
+
await self._update_worker_execution_stats(old_execution, execution, now)
|
|
694
|
+
|
|
695
|
+
async def get_execution(
|
|
696
|
+
self, execution_id: str, session_id: str = ""
|
|
697
|
+
) -> Optional[dict[str, Any]]:
|
|
698
|
+
"""
|
|
699
|
+
Get execution details.
|
|
700
|
+
|
|
701
|
+
Note: In the new architecture, callers should provide session_id to
|
|
702
|
+
optimize query performance. If session_id is not provided, global
|
|
703
|
+
search is needed (which is not recommended).
|
|
704
|
+
"""
|
|
705
|
+
if not session_id:
|
|
706
|
+
logger.warning(
|
|
707
|
+
"get_execution called without session_id, this is inefficient in the "
|
|
708
|
+
"new registry architecture."
|
|
709
|
+
)
|
|
710
|
+
# Compatibility logic: if session_id is truly unavailable, may need to
|
|
711
|
+
# scan all or return error
|
|
712
|
+
return None
|
|
713
|
+
|
|
714
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
715
|
+
data = await self.redis.hget(reg_key, f"{EXEC_FIELD_PREFIX}{execution_id}")
|
|
716
|
+
if not data:
|
|
717
|
+
return None
|
|
718
|
+
|
|
719
|
+
if isinstance(data, bytes):
|
|
720
|
+
data = data.decode("utf-8")
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
return json.loads(data)
|
|
724
|
+
except json.JSONDecodeError as err:
|
|
725
|
+
raise ExecutionDataError(execution_id, cause=err) from err
|
|
726
|
+
|
|
727
|
+
async def get_execution_by_message_id(
|
|
728
|
+
self, message_id: str, session_id: str = ""
|
|
729
|
+
) -> Optional[dict[str, Any]]:
|
|
730
|
+
"""
|
|
731
|
+
Get execution details by message_id.
|
|
732
|
+
"""
|
|
733
|
+
if not session_id:
|
|
734
|
+
# In some flows (like cancellation), only message_id may be available.
|
|
735
|
+
# To support this, we maintain session_id passing on the GatewayClient side.
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
739
|
+
execution_id = await self.redis.hget(reg_key, f"{MSG_MAP_PREFIX}{message_id}")
|
|
740
|
+
if isinstance(execution_id, bytes):
|
|
741
|
+
execution_id = execution_id.decode("utf-8")
|
|
742
|
+
|
|
743
|
+
if not execution_id:
|
|
744
|
+
return None
|
|
745
|
+
return await self.get_execution(execution_id, session_id)
|
|
746
|
+
|
|
747
|
+
async def mark_execution_cancelling(
|
|
748
|
+
self, execution_id: str, session_id: str, reason: str
|
|
749
|
+
):
|
|
750
|
+
"""Mark execution status as CANCELLING.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
execution_id: Execution ID
|
|
754
|
+
session_id: Session ID
|
|
755
|
+
reason: Cancellation reason
|
|
756
|
+
"""
|
|
757
|
+
current = await self.get_execution(execution_id, session_id)
|
|
758
|
+
if current is None:
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
old_execution = dict(current)
|
|
762
|
+
current["status"] = "CANCELLING"
|
|
763
|
+
current["cancel_requested"] = True
|
|
764
|
+
current["cancel_reason"] = reason
|
|
765
|
+
now = int(time.time() * 1000)
|
|
766
|
+
current["updated_at"] = now
|
|
767
|
+
|
|
768
|
+
timeline = current.get("timeline", [])
|
|
769
|
+
timeline.append({"status": "CANCELLING", "timestamp": now})
|
|
770
|
+
current["timeline"] = timeline
|
|
771
|
+
|
|
772
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
773
|
+
pipe = self.redis.pipeline()
|
|
774
|
+
pipe.hset(
|
|
775
|
+
reg_key,
|
|
776
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
777
|
+
json.dumps(current, ensure_ascii=False),
|
|
778
|
+
)
|
|
779
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
780
|
+
await pipe.execute()
|
|
781
|
+
await self._update_worker_execution_stats(old_execution, current, now)
|
|
782
|
+
|
|
783
|
+
async def mark_cancel_requested(
|
|
784
|
+
self, execution_id: str, session_id: str, reason: str = ""
|
|
785
|
+
):
|
|
786
|
+
"""Only mark the cancel_requested flag, do not change execution status.
|
|
787
|
+
|
|
788
|
+
Used in cascade cancellation scenarios to mark completed (COMPLETED)
|
|
789
|
+
parent nodes, so that cancelled child agents know they don't need to
|
|
790
|
+
callback and wake up the parent.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
execution_id: Execution ID
|
|
794
|
+
session_id: Session ID
|
|
795
|
+
reason: Cancellation reason
|
|
796
|
+
"""
|
|
797
|
+
current = await self.get_execution(execution_id, session_id)
|
|
798
|
+
if current is None:
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
old_execution = dict(current)
|
|
802
|
+
current["cancel_requested"] = True
|
|
803
|
+
if reason:
|
|
804
|
+
current["cancel_reason"] = reason
|
|
805
|
+
current["updated_at"] = int(time.time() * 1000)
|
|
806
|
+
|
|
807
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
808
|
+
pipe = self.redis.pipeline()
|
|
809
|
+
pipe.hset(
|
|
810
|
+
reg_key,
|
|
811
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
812
|
+
json.dumps(current, ensure_ascii=False),
|
|
813
|
+
)
|
|
814
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
815
|
+
await pipe.execute()
|
|
816
|
+
await self._update_worker_execution_stats(
|
|
817
|
+
old_execution, current, current["updated_at"]
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
async def mark_execution_finished(
|
|
821
|
+
self,
|
|
822
|
+
execution_id: str,
|
|
823
|
+
session_id: str,
|
|
824
|
+
status: str,
|
|
825
|
+
completion: Optional[ExecutionCompletionFields] = None,
|
|
826
|
+
):
|
|
827
|
+
"""Mark execution as finished status.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
execution_id: Execution ID
|
|
831
|
+
session_id: Session ID
|
|
832
|
+
status: Final status
|
|
833
|
+
completion: Optional structured terminal metadata for observability.
|
|
834
|
+
"""
|
|
835
|
+
current = await self.get_execution(execution_id, session_id)
|
|
836
|
+
if current is None:
|
|
837
|
+
return
|
|
838
|
+
|
|
839
|
+
old_execution = dict(current)
|
|
840
|
+
current["status"] = status
|
|
841
|
+
now = int(time.time() * 1000)
|
|
842
|
+
current["finished_at"] = now
|
|
843
|
+
current["updated_at"] = now
|
|
844
|
+
if completion:
|
|
845
|
+
for key, value in completion.items():
|
|
846
|
+
current[key] = value
|
|
847
|
+
|
|
848
|
+
timeline = current.get("timeline", [])
|
|
849
|
+
timeline.append({"status": status, "timestamp": now})
|
|
850
|
+
current["timeline"] = timeline
|
|
851
|
+
|
|
852
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
853
|
+
pipe = self.redis.pipeline()
|
|
854
|
+
pipe.hset(
|
|
855
|
+
reg_key,
|
|
856
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
857
|
+
json.dumps(current, ensure_ascii=False),
|
|
858
|
+
)
|
|
859
|
+
pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
860
|
+
await pipe.execute()
|
|
861
|
+
await self._update_worker_execution_stats(old_execution, current, now)
|
|
862
|
+
|
|
863
|
+
snapshot_key = current.get("agent_configs_snapshot_key", "")
|
|
864
|
+
if snapshot_key and is_terminal_state(status):
|
|
865
|
+
try:
|
|
866
|
+
await self.delete_agent_configs_snapshot(snapshot_key)
|
|
867
|
+
current["agent_configs_snapshot_cleanup_status"] = "deleted"
|
|
868
|
+
current["agent_configs_snapshot_cleaned_at"] = int(time.time() * 1000)
|
|
869
|
+
current["agent_configs_snapshot_cleanup_error"] = ""
|
|
870
|
+
except Exception as err: # pylint: disable=broad-exception-caught
|
|
871
|
+
current["agent_configs_snapshot_cleanup_status"] = "delete_failed"
|
|
872
|
+
current["agent_configs_snapshot_cleaned_at"] = 0
|
|
873
|
+
current["agent_configs_snapshot_cleanup_error"] = str(err)
|
|
874
|
+
logger.warning(
|
|
875
|
+
"Failed to delete persisted agent config snapshot: "
|
|
876
|
+
"execution_id=%s session_id=%s snapshot_key=%s error=%s",
|
|
877
|
+
execution_id,
|
|
878
|
+
session_id,
|
|
879
|
+
snapshot_key,
|
|
880
|
+
err,
|
|
881
|
+
)
|
|
882
|
+
finally:
|
|
883
|
+
current["updated_at"] = int(time.time() * 1000)
|
|
884
|
+
cleanup_pipe = self.redis.pipeline()
|
|
885
|
+
cleanup_pipe.hset(
|
|
886
|
+
reg_key,
|
|
887
|
+
f"{EXEC_FIELD_PREFIX}{execution_id}",
|
|
888
|
+
json.dumps(current, ensure_ascii=False),
|
|
889
|
+
)
|
|
890
|
+
cleanup_pipe.expire(reg_key, RedisKeys.DEFAULT_SESSION_TTL)
|
|
891
|
+
await cleanup_pipe.execute()
|
|
892
|
+
|
|
893
|
+
async def record_failed_route_decision(
|
|
894
|
+
self,
|
|
895
|
+
*,
|
|
896
|
+
execution_id: str,
|
|
897
|
+
message_id: str,
|
|
898
|
+
session_id: str,
|
|
899
|
+
trace_id: str,
|
|
900
|
+
parent_message_id: str,
|
|
901
|
+
source_agent_type: str,
|
|
902
|
+
target_agent_type: str,
|
|
903
|
+
route_policy: str,
|
|
904
|
+
route_status: str,
|
|
905
|
+
stream_name: str,
|
|
906
|
+
selected_agent_type: str,
|
|
907
|
+
availability_error_code: str,
|
|
908
|
+
availability_error: str,
|
|
909
|
+
) -> None:
|
|
910
|
+
"""Persist a failed availability routing decision for the dashboard."""
|
|
911
|
+
try:
|
|
912
|
+
await self.initialize_execution(
|
|
913
|
+
{
|
|
914
|
+
"execution_id": execution_id,
|
|
915
|
+
"message_id": message_id,
|
|
916
|
+
"session_id": session_id,
|
|
917
|
+
"trace_id": trace_id,
|
|
918
|
+
"parent_message_id": parent_message_id,
|
|
919
|
+
"source_agent_type": source_agent_type,
|
|
920
|
+
"target_agent_type": target_agent_type,
|
|
921
|
+
"stream_name": stream_name,
|
|
922
|
+
"status": "FAILED",
|
|
923
|
+
"route_policy": route_policy,
|
|
924
|
+
"route_status": route_status,
|
|
925
|
+
"selected_agent_type": selected_agent_type,
|
|
926
|
+
"availability_error_code": availability_error_code,
|
|
927
|
+
"availability_error": availability_error,
|
|
928
|
+
"error_code": availability_error_code,
|
|
929
|
+
"error_message": availability_error,
|
|
930
|
+
"failed_stage": "availability",
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
934
|
+
pass
|
|
935
|
+
|
|
936
|
+
async def get_all_session_executions(self, session_id: str) -> list[dict[str, Any]]:
|
|
937
|
+
"""Get all execution records under the specified Session.
|
|
938
|
+
|
|
939
|
+
Used in cascade cancellation scenarios, fetches the entire Session's
|
|
940
|
+
registry data through HGETALL at once, filters out all entries with
|
|
941
|
+
exec: prefix and deserializes them.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
session_id: Session ID
|
|
945
|
+
|
|
946
|
+
Returns:
|
|
947
|
+
List of all execution records under this session
|
|
948
|
+
"""
|
|
949
|
+
reg_key = RedisKeys.session_registry(session_id)
|
|
950
|
+
all_data = await self.redis.hgetall(reg_key)
|
|
951
|
+
executions = []
|
|
952
|
+
for field, value in all_data.items():
|
|
953
|
+
field_str = field.decode() if isinstance(field, bytes) else field
|
|
954
|
+
if not field_str.startswith(EXEC_FIELD_PREFIX):
|
|
955
|
+
continue
|
|
956
|
+
value_str = value.decode("utf-8") if isinstance(value, bytes) else value
|
|
957
|
+
try:
|
|
958
|
+
executions.append(json.loads(value_str))
|
|
959
|
+
except json.JSONDecodeError:
|
|
960
|
+
continue
|
|
961
|
+
return executions
|
|
962
|
+
|
|
963
|
+
async def get_worker_executions(
|
|
964
|
+
self,
|
|
965
|
+
worker_id: str,
|
|
966
|
+
*,
|
|
967
|
+
include_terminal: bool = True,
|
|
968
|
+
limit: int = 100,
|
|
969
|
+
) -> list[dict[str, Any]]:
|
|
970
|
+
"""Get recent lightweight execution snapshots assigned to a Worker."""
|
|
971
|
+
if limit <= 0:
|
|
972
|
+
return []
|
|
973
|
+
|
|
974
|
+
execution_ids = await self.redis.zrevrange(
|
|
975
|
+
RedisKeys.worker_executions(worker_id), 0, limit - 1
|
|
976
|
+
)
|
|
977
|
+
return await self._get_worker_snapshots(
|
|
978
|
+
RedisKeys.worker_history_snapshots(worker_id),
|
|
979
|
+
execution_ids,
|
|
980
|
+
include_terminal=include_terminal,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
async def _get_worker_snapshots(
|
|
984
|
+
self,
|
|
985
|
+
snapshots_key: str,
|
|
986
|
+
raw_execution_ids: list[Any],
|
|
987
|
+
*,
|
|
988
|
+
include_terminal: bool = True,
|
|
989
|
+
) -> list[dict[str, Any]]:
|
|
990
|
+
"""Fetch worker execution snapshots in batch and preserve ID order."""
|
|
991
|
+
execution_ids = [
|
|
992
|
+
raw_execution_id.decode("utf-8")
|
|
993
|
+
if isinstance(raw_execution_id, bytes)
|
|
994
|
+
else str(raw_execution_id)
|
|
995
|
+
for raw_execution_id in raw_execution_ids
|
|
996
|
+
]
|
|
997
|
+
if not execution_ids:
|
|
998
|
+
return []
|
|
999
|
+
|
|
1000
|
+
raw_snapshots = await self.redis.hmget(snapshots_key, execution_ids)
|
|
1001
|
+
snapshots: list[dict[str, Any]] = []
|
|
1002
|
+
for raw_snapshot in raw_snapshots:
|
|
1003
|
+
if not raw_snapshot:
|
|
1004
|
+
continue
|
|
1005
|
+
snapshot_data = (
|
|
1006
|
+
raw_snapshot.decode("utf-8")
|
|
1007
|
+
if isinstance(raw_snapshot, bytes)
|
|
1008
|
+
else str(raw_snapshot)
|
|
1009
|
+
)
|
|
1010
|
+
try:
|
|
1011
|
+
snapshot = json.loads(snapshot_data)
|
|
1012
|
+
except json.JSONDecodeError:
|
|
1013
|
+
continue
|
|
1014
|
+
status = str(snapshot.get("status") or "")
|
|
1015
|
+
if not include_terminal and is_terminal_state(status):
|
|
1016
|
+
continue
|
|
1017
|
+
snapshots.append(snapshot)
|
|
1018
|
+
|
|
1019
|
+
return snapshots
|
|
1020
|
+
|
|
1021
|
+
@staticmethod
|
|
1022
|
+
def _get_int_hash_value(data: dict[Any, Any], key: str) -> int:
|
|
1023
|
+
value = data.get(key)
|
|
1024
|
+
if value is None:
|
|
1025
|
+
value = data.get(key.encode("utf-8"))
|
|
1026
|
+
if isinstance(value, bytes):
|
|
1027
|
+
value = value.decode("utf-8")
|
|
1028
|
+
return int(value or 0)
|
|
1029
|
+
|
|
1030
|
+
async def get_worker_execution_summary(
|
|
1031
|
+
self,
|
|
1032
|
+
worker_id: str,
|
|
1033
|
+
*,
|
|
1034
|
+
active_limit: int = 100,
|
|
1035
|
+
history_limit: int = 20,
|
|
1036
|
+
limit: Optional[int] = None,
|
|
1037
|
+
workers: Optional[dict[str, Any]] = None,
|
|
1038
|
+
) -> dict[str, Any]:
|
|
1039
|
+
"""Get worker liveness metadata and recent execution state summary."""
|
|
1040
|
+
if limit is not None:
|
|
1041
|
+
history_limit = limit
|
|
1042
|
+
|
|
1043
|
+
workers = workers if workers is not None else await self.get_all_workers()
|
|
1044
|
+
worker_info = workers.get(worker_id, {})
|
|
1045
|
+
raw_counts = await self.redis.hgetall(RedisKeys.worker_status(worker_id))
|
|
1046
|
+
counts = {
|
|
1047
|
+
"total": self._get_int_hash_value(raw_counts, "total_count"),
|
|
1048
|
+
"active": self._get_int_hash_value(raw_counts, "active_count"),
|
|
1049
|
+
"queued": self._get_int_hash_value(raw_counts, "queued_count"),
|
|
1050
|
+
"running": self._get_int_hash_value(raw_counts, "running_count"),
|
|
1051
|
+
"cancelling": self._get_int_hash_value(raw_counts, "cancelling_count"),
|
|
1052
|
+
"completed": self._get_int_hash_value(raw_counts, "completed_count"),
|
|
1053
|
+
"failed": self._get_int_hash_value(raw_counts, "failed_count"),
|
|
1054
|
+
"cancelled": self._get_int_hash_value(raw_counts, "cancelled_count"),
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
active_executions = []
|
|
1058
|
+
if active_limit > 0:
|
|
1059
|
+
active_ids = await self.redis.zrevrange(
|
|
1060
|
+
RedisKeys.worker_active_execution_index(worker_id), 0, active_limit - 1
|
|
1061
|
+
)
|
|
1062
|
+
active_executions = await self._get_worker_snapshots(
|
|
1063
|
+
RedisKeys.worker_active_snapshots(worker_id), active_ids
|
|
1064
|
+
)
|
|
1065
|
+
recent_executions = await self.get_worker_executions(
|
|
1066
|
+
worker_id, limit=history_limit
|
|
1067
|
+
)
|
|
1068
|
+
status_counts = {
|
|
1069
|
+
"QUEUED": counts["queued"],
|
|
1070
|
+
"RUNNING": counts["running"],
|
|
1071
|
+
"CANCELLING": counts["cancelling"],
|
|
1072
|
+
"COMPLETED": counts["completed"],
|
|
1073
|
+
"FAILED": counts["failed"],
|
|
1074
|
+
"CANCELLED": counts["cancelled"],
|
|
1075
|
+
}
|
|
1076
|
+
status_counts = {key: value for key, value in status_counts.items() if value}
|
|
1077
|
+
|
|
1078
|
+
return {
|
|
1079
|
+
"worker_id": worker_id,
|
|
1080
|
+
"online": worker_id in workers,
|
|
1081
|
+
"agent_types": sorted(worker_info.get("agent_types", [])),
|
|
1082
|
+
"last_seen": int(worker_info.get("last_seen", 0)),
|
|
1083
|
+
"counts": counts,
|
|
1084
|
+
"active_count": counts["active"],
|
|
1085
|
+
"total_tracked": counts["total"],
|
|
1086
|
+
"last_updated_at": self._get_int_hash_value(raw_counts, "last_updated_at"),
|
|
1087
|
+
"last_started_at": self._get_int_hash_value(raw_counts, "last_started_at"),
|
|
1088
|
+
"last_finished_at": self._get_int_hash_value(
|
|
1089
|
+
raw_counts, "last_finished_at"
|
|
1090
|
+
),
|
|
1091
|
+
"status_counts": status_counts,
|
|
1092
|
+
"active_executions": active_executions,
|
|
1093
|
+
"recent_executions": recent_executions,
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
def _encode_execution(self, execution: dict[str, Any]) -> dict[str, str]: # pylint: disable=unused-argument
|
|
1097
|
+
# Deprecated, since we switched to JSON storage
|
|
1098
|
+
return {}
|
|
1099
|
+
|
|
1100
|
+
def _decode_execution(self, execution: dict[str, Any]) -> dict[str, Any]: # pylint: disable=unused-argument
|
|
1101
|
+
# Deprecated, since we switched to JSON storage
|
|
1102
|
+
return {}
|