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.
Files changed (49) hide show
  1. by_framework/client/client.py +15 -7
  2. by_framework/core/__init__.py +69 -0
  3. by_framework/core/availability.py +495 -0
  4. by_framework/core/delivery_gate.py +60 -0
  5. by_framework/core/discovery.py +359 -0
  6. by_framework/core/extensions/__init__.py +29 -0
  7. by_framework/core/extensions/agent_config.py +64 -0
  8. by_framework/core/extensions/plugin.py +312 -0
  9. by_framework/core/extensions/registry.py +704 -0
  10. by_framework/core/extensions/trace_provider.py +20 -0
  11. by_framework/core/protocol/__init__.py +99 -0
  12. by_framework/core/protocol/action_type.py +33 -0
  13. by_framework/core/protocol/agent_state.py +78 -0
  14. by_framework/core/protocol/byai_codec.py +96 -0
  15. by_framework/core/protocol/byai_command.py +53 -0
  16. by_framework/core/protocol/byai_types.py +7 -0
  17. by_framework/core/protocol/commands.py +285 -0
  18. by_framework/core/protocol/content_codec.py +17 -0
  19. by_framework/core/protocol/content_type.py +38 -0
  20. by_framework/core/protocol/data_message.py +45 -0
  21. by_framework/core/protocol/data_shapes.py +83 -0
  22. by_framework/core/protocol/event_type.py +34 -0
  23. by_framework/core/protocol/events.py +69 -0
  24. by_framework/core/protocol/message.py +99 -0
  25. by_framework/core/protocol/message_header.py +78 -0
  26. by_framework/core/protocol/responses.py +94 -0
  27. by_framework/core/protocol/results.py +149 -0
  28. by_framework/core/registry.py +1102 -0
  29. by_framework/core/runtime/__init__.py +27 -0
  30. by_framework/core/runtime/agent_config_manager.py +283 -0
  31. by_framework/core/runtime/agent_runtime_state.py +75 -0
  32. by_framework/core/runtime/file_manager.py +434 -0
  33. by_framework/core/runtime/file_paths.py +76 -0
  34. by_framework/core/runtime/file_permissions.py +71 -0
  35. by_framework/core/runtime/filestore/__init__.py +15 -0
  36. by_framework/core/runtime/filestore/base.py +140 -0
  37. by_framework/core/runtime/filestore/local.py +313 -0
  38. by_framework/core/runtime/history/__init__.py +10 -0
  39. by_framework/core/runtime/history/base.py +57 -0
  40. by_framework/core/runtime/history/history_manager.py +55 -0
  41. by_framework/core/runtime/history/in_memory.py +58 -0
  42. by_framework/core/runtime/session_manager.py +118 -0
  43. by_framework/core/wakeup_controller.py +149 -0
  44. by_framework/core/workspace.py +126 -0
  45. {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/METADATA +1 -1
  46. by_framework-0.2.2.dev4.dist-info/RECORD +92 -0
  47. by_framework-0.2.2.dev2.dist-info/RECORD +0 -49
  48. {by_framework-0.2.2.dev2.dist-info → by_framework-0.2.2.dev4.dist-info}/WHEEL +0 -0
  49. {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 {}