by-framework 0.2.2.dev4__py3-none-any.whl → 0.2.2.dev6__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/__init__.py +8 -0
- by_framework/admin/__init__.py +5 -0
- by_framework/admin/cli.py +344 -0
- by_framework/admin/worker_manager.py +146 -0
- by_framework/client/client.py +102 -2
- by_framework/common/__init__.py +8 -1
- by_framework/common/config.py +2 -0
- by_framework/common/constants.py +28 -1
- by_framework/common/logger.py +36 -11
- by_framework/common/redis_client.py +13 -0
- by_framework/core/__init__.py +40 -14
- by_framework/core/extensions/__init__.py +11 -3
- by_framework/core/extensions/agent_config_audit.py +144 -0
- by_framework/core/extensions/plugin.py +5 -3
- by_framework/core/extensions/registry.py +12 -5
- by_framework/core/protocol/__init__.py +59 -19
- by_framework/core/protocol/action_type.py +11 -1
- by_framework/core/protocol/byai_codec.py +7 -2
- by_framework/core/protocol/commands.py +80 -1
- by_framework/core/registry.py +362 -72
- by_framework/core/runtime/__init__.py +4 -2
- by_framework/core/runtime/file_manager.py +15 -12
- by_framework/core/runtime/filestore/local.py +12 -4
- by_framework/core/wakeup_controller.py +5 -3
- by_framework/metrics/__init__.py +107 -0
- by_framework/metrics/catalog.py +409 -0
- by_framework/metrics/collector.py +228 -0
- by_framework/metrics/read_client.py +243 -0
- by_framework/metrics/snapshot.py +536 -43
- by_framework/trace/__init__.py +2 -0
- by_framework/trace/span_recorder.py +30 -0
- by_framework/util/discovery_http_client.py +4 -3
- by_framework/worker/_control_handling.py +47 -12
- by_framework/worker/context.py +3 -65
- by_framework/worker/heartbeat.py +281 -20
- by_framework/worker/runner.py +283 -12
- by_framework/worker/worker.py +26 -2
- {by_framework-0.2.2.dev4.dist-info → by_framework-0.2.2.dev6.dist-info}/METADATA +6 -1
- {by_framework-0.2.2.dev4.dist-info → by_framework-0.2.2.dev6.dist-info}/RECORD +42 -35
- by_framework-0.2.2.dev6.dist-info/entry_points.txt +2 -0
- {by_framework-0.2.2.dev4.dist-info → by_framework-0.2.2.dev6.dist-info}/WHEEL +0 -0
- {by_framework-0.2.2.dev4.dist-info → by_framework-0.2.2.dev6.dist-info}/licenses/LICENSE +0 -0
by_framework/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ Allows developers to quickly start a Redis-based agent node by inheriting
|
|
|
5
5
|
from `GatewayWorker` and running `run_worker`.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from .admin import WorkerManager
|
|
8
9
|
from .client.byai_client import ByaiGatewayClient
|
|
9
10
|
from .client.client import (
|
|
10
11
|
CancelTaskResponse,
|
|
@@ -59,9 +60,12 @@ from .core.protocol.commands import (
|
|
|
59
60
|
AskAgentCommand,
|
|
60
61
|
BaseCommand,
|
|
61
62
|
CancelTaskCommand,
|
|
63
|
+
EvictWorkerCommand,
|
|
62
64
|
GatewayCommand,
|
|
63
65
|
ReloadPluginsCommand,
|
|
64
66
|
ResumeCommand,
|
|
67
|
+
ResumeWorkerCommand,
|
|
68
|
+
SuspendWorkerCommand,
|
|
65
69
|
command_from_dict,
|
|
66
70
|
get_registered_command,
|
|
67
71
|
register_command,
|
|
@@ -152,6 +156,7 @@ __all__ = [
|
|
|
152
156
|
"close_redis",
|
|
153
157
|
"Redis",
|
|
154
158
|
"WorkerRegistry",
|
|
159
|
+
"WorkerManager",
|
|
155
160
|
"check_agent_type_online",
|
|
156
161
|
"check_worker_online",
|
|
157
162
|
"RedisKeys",
|
|
@@ -168,6 +173,9 @@ __all__ = [
|
|
|
168
173
|
"ResumeCommand",
|
|
169
174
|
"CancelTaskCommand",
|
|
170
175
|
"ReloadPluginsCommand",
|
|
176
|
+
"SuspendWorkerCommand",
|
|
177
|
+
"ResumeWorkerCommand",
|
|
178
|
+
"EvictWorkerCommand",
|
|
171
179
|
"GatewayCommand",
|
|
172
180
|
"command_from_dict",
|
|
173
181
|
"register_command",
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""by-admin — by-framework cluster admin CLI."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=global-statement
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from by_framework.admin.worker_manager import WorkerManager
|
|
16
|
+
from by_framework.common.redis_client import init_redis_from_url
|
|
17
|
+
from by_framework.core.registry import WorkerRegistry
|
|
18
|
+
from by_framework.metrics.snapshot import (
|
|
19
|
+
build_observability_snapshot,
|
|
20
|
+
load_history_from_redis,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# App hierarchy
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="by-admin",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
help="by-framework cluster admin CLI",
|
|
31
|
+
)
|
|
32
|
+
worker_app = typer.Typer(no_args_is_help=True)
|
|
33
|
+
type_app = typer.Typer(no_args_is_help=True)
|
|
34
|
+
metrics_app = typer.Typer(no_args_is_help=True)
|
|
35
|
+
|
|
36
|
+
app.add_typer(worker_app, name="worker", help="Worker lifecycle management")
|
|
37
|
+
app.add_typer(type_app, name="type", help="Agent-type admission control")
|
|
38
|
+
app.add_typer(metrics_app, name="metrics", help="Metrics and observability")
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
# Global state + helpers
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
|
|
44
|
+
console = Console()
|
|
45
|
+
err_console = Console(stderr=True)
|
|
46
|
+
_redis_url = "redis://localhost:6379/0"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def _global(
|
|
51
|
+
redis_url: Optional[str] = typer.Option(
|
|
52
|
+
None,
|
|
53
|
+
"--redis-url",
|
|
54
|
+
envvar=["BYAI_REDIS_URL", "REDIS_URL"],
|
|
55
|
+
help="Redis connection URL [env: BYAI_REDIS_URL]",
|
|
56
|
+
),
|
|
57
|
+
):
|
|
58
|
+
"""by-framework cluster admin CLI."""
|
|
59
|
+
global _redis_url
|
|
60
|
+
if redis_url:
|
|
61
|
+
_redis_url = redis_url
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run(coro): # type: ignore[no-untyped-def]
|
|
65
|
+
return asyncio.run(coro)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _die(msg: str, code: int = 1) -> None:
|
|
69
|
+
err_console.print(f"[red]Error:[/red] {msg}")
|
|
70
|
+
raise typer.Exit(code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_redis():
|
|
74
|
+
return init_redis_from_url(_redis_url)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------- #
|
|
78
|
+
# worker subcommands
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
|
|
81
|
+
_LIFECYCLE_COLORS = {"active": "green", "suspended": "yellow", "evicted": "red"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@worker_app.command("list")
|
|
85
|
+
def worker_list(
|
|
86
|
+
agent_type: Optional[str] = typer.Option(
|
|
87
|
+
None, "--type", "-t", help="Filter by agent type"
|
|
88
|
+
),
|
|
89
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
90
|
+
):
|
|
91
|
+
"""List all online workers."""
|
|
92
|
+
|
|
93
|
+
async def _inner():
|
|
94
|
+
registry = WorkerRegistry(_get_redis())
|
|
95
|
+
workers = await registry.get_all_workers()
|
|
96
|
+
|
|
97
|
+
rows = [{"worker_id": wid, **data} for wid, data in workers.items()]
|
|
98
|
+
if agent_type:
|
|
99
|
+
rows = [r for r in rows if agent_type in r.get("agent_types", [])]
|
|
100
|
+
|
|
101
|
+
if as_json:
|
|
102
|
+
typer.echo(json.dumps(rows, default=str))
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if not rows:
|
|
106
|
+
console.print("[dim]No workers found.[/dim]")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
table = Table(show_header=True, header_style="bold")
|
|
110
|
+
table.add_column("Worker ID")
|
|
111
|
+
table.add_column("Lifecycle")
|
|
112
|
+
table.add_column("Agent Types")
|
|
113
|
+
table.add_column("IP")
|
|
114
|
+
table.add_column("Last Seen (ms)")
|
|
115
|
+
|
|
116
|
+
for w in rows:
|
|
117
|
+
lc = w.get("lifecycle", "active")
|
|
118
|
+
color = _LIFECYCLE_COLORS.get(lc, "white")
|
|
119
|
+
table.add_row(
|
|
120
|
+
w["worker_id"],
|
|
121
|
+
f"[{color}]{lc}[/{color}]",
|
|
122
|
+
", ".join(w.get("agent_types", [])),
|
|
123
|
+
w.get("ip_address", ""),
|
|
124
|
+
str(w.get("last_seen", "")),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
console.print(table)
|
|
128
|
+
console.print(f"\n[dim]{len(rows)} worker(s)[/dim]")
|
|
129
|
+
|
|
130
|
+
_run(_inner())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@worker_app.command("info")
|
|
134
|
+
def worker_info(
|
|
135
|
+
worker_id: str = typer.Argument(..., help="Worker ID"),
|
|
136
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
137
|
+
):
|
|
138
|
+
"""Show detailed info for a single worker."""
|
|
139
|
+
|
|
140
|
+
async def _inner():
|
|
141
|
+
registry = WorkerRegistry(_get_redis())
|
|
142
|
+
workers = await registry.get_all_workers()
|
|
143
|
+
worker = workers.get(worker_id)
|
|
144
|
+
|
|
145
|
+
if worker is None:
|
|
146
|
+
_die(f"worker '{worker_id}' not found")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
result = {"worker_id": worker_id, **worker}
|
|
150
|
+
if as_json:
|
|
151
|
+
typer.echo(json.dumps(result, default=str))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
lc = result.get("lifecycle", "active")
|
|
155
|
+
color = _LIFECYCLE_COLORS.get(lc, "white")
|
|
156
|
+
lc_reason = result.get("lifecycle_reason", "")
|
|
157
|
+
agent_types_str = ", ".join(result.get("agent_types", []))
|
|
158
|
+
ip_address = result.get("ip_address", "")
|
|
159
|
+
last_seen = result.get("last_seen", "")
|
|
160
|
+
console.print(f"[bold]Worker:[/bold] {worker_id}")
|
|
161
|
+
console.print(f" lifecycle: [{color}]{lc}[/{color}]")
|
|
162
|
+
console.print(f" lifecycle_reason: {lc_reason}")
|
|
163
|
+
console.print(f" agent_types: {agent_types_str}")
|
|
164
|
+
console.print(f" ip_address: {ip_address}")
|
|
165
|
+
console.print(f" last_seen (ms): {last_seen}")
|
|
166
|
+
|
|
167
|
+
_run(_inner())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@worker_app.command("suspend")
|
|
171
|
+
def worker_suspend(
|
|
172
|
+
worker_id: str = typer.Argument(..., help="Worker ID"),
|
|
173
|
+
reason: str = typer.Option("", "--reason", "-r", help="Reason for suspension"),
|
|
174
|
+
):
|
|
175
|
+
"""Suspend a worker (stops consuming, stays online)."""
|
|
176
|
+
|
|
177
|
+
async def _inner():
|
|
178
|
+
mgr = WorkerManager(_get_redis())
|
|
179
|
+
await mgr.suspend_worker(worker_id, reason=reason)
|
|
180
|
+
console.print(f"[green]✓[/green] Suspended {worker_id}")
|
|
181
|
+
|
|
182
|
+
_run(_inner())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@worker_app.command("resume")
|
|
186
|
+
def worker_resume(
|
|
187
|
+
worker_id: str = typer.Argument(..., help="Worker ID"),
|
|
188
|
+
):
|
|
189
|
+
"""Resume a suspended worker."""
|
|
190
|
+
|
|
191
|
+
async def _inner():
|
|
192
|
+
mgr = WorkerManager(_get_redis())
|
|
193
|
+
await mgr.resume_worker(worker_id)
|
|
194
|
+
console.print(f"[green]✓[/green] Resumed {worker_id}")
|
|
195
|
+
|
|
196
|
+
_run(_inner())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@worker_app.command("evict")
|
|
200
|
+
def worker_evict(
|
|
201
|
+
worker_id: str = typer.Argument(..., help="Worker ID"),
|
|
202
|
+
force: bool = typer.Option(
|
|
203
|
+
False, "--force", help="Cancel in-flight tasks immediately"
|
|
204
|
+
),
|
|
205
|
+
):
|
|
206
|
+
"""Evict a worker (clears lease, removes from routing)."""
|
|
207
|
+
|
|
208
|
+
async def _inner():
|
|
209
|
+
mgr = WorkerManager(_get_redis())
|
|
210
|
+
await mgr.evict_worker(worker_id, force=force)
|
|
211
|
+
console.print(f"[green]✓[/green] Evicted {worker_id}")
|
|
212
|
+
|
|
213
|
+
_run(_inner())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
# type subcommands
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@type_app.command("denylist")
|
|
222
|
+
def type_denylist(
|
|
223
|
+
agent_type: str = typer.Argument(..., help="Agent type name"),
|
|
224
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
225
|
+
):
|
|
226
|
+
"""Show all workers denied for an agent type."""
|
|
227
|
+
|
|
228
|
+
async def _inner():
|
|
229
|
+
mgr = WorkerManager(_get_redis())
|
|
230
|
+
denied = await mgr.get_type_denylist(agent_type)
|
|
231
|
+
if as_json:
|
|
232
|
+
typer.echo(json.dumps(denied))
|
|
233
|
+
return
|
|
234
|
+
if not denied:
|
|
235
|
+
console.print(f"[dim]No denied workers for type '{agent_type}'[/dim]")
|
|
236
|
+
return
|
|
237
|
+
for wid in denied:
|
|
238
|
+
console.print(f" {wid}")
|
|
239
|
+
|
|
240
|
+
_run(_inner())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@type_app.command("deny")
|
|
244
|
+
def type_deny(
|
|
245
|
+
agent_type: str = typer.Argument(..., help="Agent type name"),
|
|
246
|
+
worker_id: str = typer.Argument(..., help="Worker ID to deny"),
|
|
247
|
+
):
|
|
248
|
+
"""Deny a worker from consuming an agent type."""
|
|
249
|
+
|
|
250
|
+
async def _inner():
|
|
251
|
+
mgr = WorkerManager(_get_redis())
|
|
252
|
+
await mgr.deny_worker_for_type(agent_type, worker_id)
|
|
253
|
+
console.print(
|
|
254
|
+
f"[green]✓[/green] Denied {worker_id} from consuming '{agent_type}'"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
_run(_inner())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@type_app.command("allow")
|
|
261
|
+
def type_allow(
|
|
262
|
+
agent_type: str = typer.Argument(..., help="Agent type name"),
|
|
263
|
+
worker_id: str = typer.Argument(..., help="Worker ID to allow"),
|
|
264
|
+
):
|
|
265
|
+
"""Remove denial for a worker on an agent type."""
|
|
266
|
+
|
|
267
|
+
async def _inner():
|
|
268
|
+
mgr = WorkerManager(_get_redis())
|
|
269
|
+
await mgr.allow_worker_for_type(agent_type, worker_id)
|
|
270
|
+
console.print(f"[green]✓[/green] Allowed {worker_id} on '{agent_type}'")
|
|
271
|
+
|
|
272
|
+
_run(_inner())
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------- #
|
|
276
|
+
# metrics subcommands
|
|
277
|
+
# --------------------------------------------------------------------------- #
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@metrics_app.command("snapshot")
|
|
281
|
+
def metrics_snapshot(
|
|
282
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
283
|
+
):
|
|
284
|
+
"""Print current cluster observability snapshot."""
|
|
285
|
+
|
|
286
|
+
async def _inner():
|
|
287
|
+
snapshot = await build_observability_snapshot(_get_redis())
|
|
288
|
+
if as_json:
|
|
289
|
+
typer.echo(json.dumps(snapshot, default=str))
|
|
290
|
+
return
|
|
291
|
+
totals = snapshot.get("totals", {})
|
|
292
|
+
workers_online = totals.get("workers_online", 0)
|
|
293
|
+
agent_types_n = totals.get("agent_types", 0)
|
|
294
|
+
active_execs = totals.get("active_executions", 0)
|
|
295
|
+
queue_depth = snapshot.get("queue_depth_total", 0)
|
|
296
|
+
console.print("[bold]Cluster Snapshot[/bold]")
|
|
297
|
+
console.print(f" Workers online: {workers_online}")
|
|
298
|
+
console.print(f" Agent types: {agent_types_n}")
|
|
299
|
+
console.print(f" Active executions: {active_execs}")
|
|
300
|
+
console.print(f" Queue depth total: {queue_depth}")
|
|
301
|
+
|
|
302
|
+
_run(_inner())
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@metrics_app.command("history")
|
|
306
|
+
def metrics_history(
|
|
307
|
+
limit: int = typer.Option(
|
|
308
|
+
20, "--limit", "-n", help="Number of history points to show"
|
|
309
|
+
),
|
|
310
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
311
|
+
):
|
|
312
|
+
"""Print recent metrics history points."""
|
|
313
|
+
|
|
314
|
+
async def _inner():
|
|
315
|
+
points = await load_history_from_redis(_get_redis(), limit=limit)
|
|
316
|
+
if as_json:
|
|
317
|
+
typer.echo(json.dumps(points, default=str))
|
|
318
|
+
return
|
|
319
|
+
if not points:
|
|
320
|
+
console.print("[dim]No history points found.[/dim]")
|
|
321
|
+
return
|
|
322
|
+
table = Table(show_header=True, header_style="bold")
|
|
323
|
+
table.add_column("Timestamp (ms)")
|
|
324
|
+
table.add_column("Workers")
|
|
325
|
+
table.add_column("Active")
|
|
326
|
+
table.add_column("Queue Depth")
|
|
327
|
+
for p in points:
|
|
328
|
+
table.add_row(
|
|
329
|
+
str(p.get("generated_at", "")),
|
|
330
|
+
str(p.get("workers_online", "")),
|
|
331
|
+
str(p.get("active_executions", "")),
|
|
332
|
+
str(p.get("queue_depth_total", "")),
|
|
333
|
+
)
|
|
334
|
+
console.print(table)
|
|
335
|
+
|
|
336
|
+
_run(_inner())
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --------------------------------------------------------------------------- #
|
|
340
|
+
# Entry point
|
|
341
|
+
# --------------------------------------------------------------------------- #
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
app()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WorkerManager — admin-side API for controlling worker lifecycle and agent-type access.
|
|
3
|
+
|
|
4
|
+
Responsibilities:
|
|
5
|
+
- Suspend / resume / evict individual workers (lifecycle control).
|
|
6
|
+
- Deny / allow workers from consuming a specific agent_type (admission control).
|
|
7
|
+
|
|
8
|
+
Lifecycle commands are delivered via two channels:
|
|
9
|
+
1. Push: XADD to byai_gateway:ctrl:worker:{worker_id} (immediate delivery).
|
|
10
|
+
2. Pull: HSET to byai_gateway:registry:worker:admin:{worker_id} (durable fallback,
|
|
11
|
+
read by the worker's heartbeat loop every heartbeat interval).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from by_framework.common.constants import RedisKeys
|
|
20
|
+
from by_framework.common.redis_client import Redis, get_redis
|
|
21
|
+
from by_framework.core.protocol.commands import (
|
|
22
|
+
EvictWorkerCommand,
|
|
23
|
+
ResumeWorkerCommand,
|
|
24
|
+
SuspendWorkerCommand,
|
|
25
|
+
)
|
|
26
|
+
from by_framework.core.protocol.message_header import MessageHeader
|
|
27
|
+
from by_framework.core.registry import WorkerRegistry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _admin_header() -> MessageHeader:
|
|
31
|
+
return MessageHeader(
|
|
32
|
+
session_id="admin",
|
|
33
|
+
trace_id=uuid.uuid4().hex,
|
|
34
|
+
message_id=uuid.uuid4().hex,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkerManager:
|
|
39
|
+
"""Admin API for worker lifecycle and agent-type admission control."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
redis_client: Optional[Redis] = None,
|
|
44
|
+
registry: Optional[WorkerRegistry] = None,
|
|
45
|
+
):
|
|
46
|
+
self.redis = redis_client or get_redis()
|
|
47
|
+
self.registry = registry or WorkerRegistry(self.redis)
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Lifecycle control
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
async def suspend_worker(self, worker_id: str, reason: str = "") -> None:
|
|
54
|
+
"""Pause a running worker from consuming new tasks.
|
|
55
|
+
|
|
56
|
+
The worker finishes in-flight tasks but stops accepting new ones.
|
|
57
|
+
Immediately removes the worker from all agent_type:members sets so that
|
|
58
|
+
routing skips it at once; the worker re-adds itself on resume.
|
|
59
|
+
"""
|
|
60
|
+
await self.registry.set_worker_admin_state(worker_id, "suspended", reason)
|
|
61
|
+
await self.registry.remove_worker_from_type_members(worker_id)
|
|
62
|
+
command = SuspendWorkerCommand(header=_admin_header(), reason=reason)
|
|
63
|
+
await self.redis.xadd(
|
|
64
|
+
RedisKeys.worker_ctrl_stream(worker_id),
|
|
65
|
+
command.to_redis_payload(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def resume_worker(self, worker_id: str) -> None:
|
|
69
|
+
"""Resume a previously suspended worker.
|
|
70
|
+
|
|
71
|
+
Re-adds the worker to all agent_type:members sets immediately (respecting
|
|
72
|
+
the denylist), so routing can reach it again without waiting for the next
|
|
73
|
+
heartbeat cycle.
|
|
74
|
+
"""
|
|
75
|
+
await self.registry.set_worker_admin_state(worker_id, "active", "")
|
|
76
|
+
await self.registry.restore_worker_to_type_members(worker_id)
|
|
77
|
+
command = ResumeWorkerCommand(header=_admin_header())
|
|
78
|
+
await self.redis.xadd(
|
|
79
|
+
RedisKeys.worker_ctrl_stream(worker_id),
|
|
80
|
+
command.to_redis_payload(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def evict_worker(
|
|
84
|
+
self,
|
|
85
|
+
worker_id: str,
|
|
86
|
+
*,
|
|
87
|
+
force: bool = False,
|
|
88
|
+
reason: str = "",
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Shut down a worker.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
worker_id: Target worker ID.
|
|
94
|
+
force: When True, cancels in-flight tasks immediately instead of
|
|
95
|
+
waiting for them to finish.
|
|
96
|
+
reason: Human-readable eviction reason.
|
|
97
|
+
|
|
98
|
+
Immediately removes the worker from all agent_type:members sets so
|
|
99
|
+
routing stops sending new messages before the heartbeat TTL expires.
|
|
100
|
+
"""
|
|
101
|
+
await self.registry.set_worker_admin_state(worker_id, "evicted", reason)
|
|
102
|
+
await self.registry.remove_worker_from_type_members(worker_id)
|
|
103
|
+
command = EvictWorkerCommand(header=_admin_header(), reason=reason, force=force)
|
|
104
|
+
await self.redis.xadd(
|
|
105
|
+
RedisKeys.worker_ctrl_stream(worker_id),
|
|
106
|
+
command.to_redis_payload(),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Agent-type admission control
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def deny_worker_for_type(self, agent_type: str, worker_id: str) -> None:
|
|
114
|
+
"""Prevent worker_id from consuming the agent_type stream.
|
|
115
|
+
|
|
116
|
+
Takes effect on the worker's next heartbeat cycle (or immediately if
|
|
117
|
+
the worker checks the denylist before each XREADGROUP call).
|
|
118
|
+
"""
|
|
119
|
+
await self.registry.deny_worker_for_type(agent_type, worker_id)
|
|
120
|
+
|
|
121
|
+
async def allow_worker_for_type(self, agent_type: str, worker_id: str) -> None:
|
|
122
|
+
"""Remove worker_id from the denylist for agent_type."""
|
|
123
|
+
await self.registry.allow_worker_for_type(agent_type, worker_id)
|
|
124
|
+
|
|
125
|
+
async def get_type_denylist(self, agent_type: str) -> list[str]:
|
|
126
|
+
"""Return all worker_ids currently denied for agent_type."""
|
|
127
|
+
return await self.registry.get_agent_type_denylist(agent_type)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Status queries
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def get_worker_admin_state(self, worker_id: str) -> dict[str, Any]:
|
|
134
|
+
"""Return the admin-controlled state for a worker.
|
|
135
|
+
|
|
136
|
+
Returns an empty dict when no admin state has been set (default active).
|
|
137
|
+
"""
|
|
138
|
+
return await self.registry.get_worker_admin_state(worker_id)
|
|
139
|
+
|
|
140
|
+
async def clear_worker_admin_state(self, worker_id: str) -> None:
|
|
141
|
+
"""Remove all admin state for a worker, restoring default-active behaviour."""
|
|
142
|
+
await self.registry.clear_worker_admin_state(worker_id)
|
|
143
|
+
|
|
144
|
+
async def allow_worker_rejoin(self, worker_id: str) -> None:
|
|
145
|
+
"""Allow a previously evicted offline worker ID to join on next startup."""
|
|
146
|
+
await self.clear_worker_admin_state(worker_id)
|
by_framework/client/client.py
CHANGED
|
@@ -43,7 +43,16 @@ from by_framework.core.protocol.responses import (
|
|
|
43
43
|
)
|
|
44
44
|
from by_framework.core.registry import WorkerRegistry
|
|
45
45
|
from by_framework.errors import WorkerRegistryNotSetError
|
|
46
|
-
from by_framework.trace.span_recorder import (
|
|
46
|
+
from by_framework.trace.span_recorder import (
|
|
47
|
+
ObservabilityConfig,
|
|
48
|
+
SpanRecorder,
|
|
49
|
+
TraceSpan,
|
|
50
|
+
build_observability_config,
|
|
51
|
+
sanitize_io_value,
|
|
52
|
+
str_to_uint64,
|
|
53
|
+
)
|
|
54
|
+
from by_framework.trace.trace_schema import TraceRecord
|
|
55
|
+
from by_framework.trace.trace_writer import TraceWriteClient
|
|
47
56
|
|
|
48
57
|
if TYPE_CHECKING:
|
|
49
58
|
pass
|
|
@@ -89,13 +98,20 @@ class GatewayClient:
|
|
|
89
98
|
redis_client: Optional[Redis] = None,
|
|
90
99
|
interceptors: Optional[List[GatewayInterceptor]] = None,
|
|
91
100
|
span_recorder: Optional[SpanRecorder] = None,
|
|
101
|
+
obs_config: Optional[ObservabilityConfig] = None,
|
|
92
102
|
):
|
|
93
103
|
self.registry = registry
|
|
94
104
|
self.redis = (
|
|
95
105
|
redis_client or (registry.redis if registry else None) or get_redis()
|
|
96
106
|
)
|
|
97
107
|
self.interceptors = interceptors or []
|
|
98
|
-
self.
|
|
108
|
+
self._obs_config = obs_config or build_observability_config()
|
|
109
|
+
self.span_recorder = span_recorder or SpanRecorder(
|
|
110
|
+
self.redis, config=self._obs_config
|
|
111
|
+
)
|
|
112
|
+
self._trace_writer = TraceWriteClient(
|
|
113
|
+
self.redis, ttl_seconds=self._obs_config.ttl_seconds
|
|
114
|
+
)
|
|
99
115
|
self._langfuse_dispatch_fn = self._resolve_langfuse_dispatch_fn()
|
|
100
116
|
|
|
101
117
|
@staticmethod
|
|
@@ -617,6 +633,20 @@ class GatewayClient:
|
|
|
617
633
|
message_id = f"{MESSAGE_ID_PREFIX}{uuid.uuid4().hex[:8]}"
|
|
618
634
|
if not trace_id:
|
|
619
635
|
trace_id = uuid.uuid4().hex
|
|
636
|
+
trace_start_ts = int(time.time() * 1000)
|
|
637
|
+
is_root_dispatch = not params.get("parent_message_id", "")
|
|
638
|
+
|
|
639
|
+
# Write trace root start so the trace is visible even before the worker
|
|
640
|
+
# picks up the task. Only written for root dispatches (no parent).
|
|
641
|
+
if is_root_dispatch:
|
|
642
|
+
await self._write_trace_root_start(
|
|
643
|
+
trace_id=trace_id,
|
|
644
|
+
message_id=message_id,
|
|
645
|
+
session_id=str(params.get("session_id", "")),
|
|
646
|
+
target_agent_type=str(params.get("target_agent_type", "")),
|
|
647
|
+
content=params.get("content"),
|
|
648
|
+
start_ts=trace_start_ts,
|
|
649
|
+
)
|
|
620
650
|
|
|
621
651
|
metadata = dict(params.get("metadata", {}) or {})
|
|
622
652
|
trace_parent_span_id = metadata.pop("trace_parent_span_id", "")
|
|
@@ -743,6 +773,13 @@ class GatewayClient:
|
|
|
743
773
|
output={"success": False, "error": availability.error},
|
|
744
774
|
error=availability.error,
|
|
745
775
|
)
|
|
776
|
+
if is_root_dispatch:
|
|
777
|
+
await self._write_trace_root_end(
|
|
778
|
+
trace_id=trace_id,
|
|
779
|
+
status="FAILED",
|
|
780
|
+
end_ts=int(time.time() * 1000),
|
|
781
|
+
error=availability.error or "",
|
|
782
|
+
)
|
|
746
783
|
return response
|
|
747
784
|
if availability.status == AvailabilityStatus.QUEUE_PENDING:
|
|
748
785
|
should_dispatch_control = False
|
|
@@ -759,6 +796,13 @@ class GatewayClient:
|
|
|
759
796
|
output={"success": False, "error": str(err)},
|
|
760
797
|
error=str(err),
|
|
761
798
|
)
|
|
799
|
+
if is_root_dispatch:
|
|
800
|
+
await self._write_trace_root_end(
|
|
801
|
+
trace_id=trace_id,
|
|
802
|
+
status="FAILED",
|
|
803
|
+
end_ts=int(time.time() * 1000),
|
|
804
|
+
error=str(err),
|
|
805
|
+
)
|
|
762
806
|
return SendMessageResponse(
|
|
763
807
|
success=False,
|
|
764
808
|
status=ExecutionStatus.FAILED,
|
|
@@ -775,6 +819,13 @@ class GatewayClient:
|
|
|
775
819
|
output={"success": False, "error": str(err)},
|
|
776
820
|
error=str(err),
|
|
777
821
|
)
|
|
822
|
+
if is_root_dispatch:
|
|
823
|
+
await self._write_trace_root_end(
|
|
824
|
+
trace_id=trace_id,
|
|
825
|
+
status="FAILED",
|
|
826
|
+
end_ts=int(time.time() * 1000),
|
|
827
|
+
error=str(err),
|
|
828
|
+
)
|
|
778
829
|
return SendMessageResponse(
|
|
779
830
|
success=False,
|
|
780
831
|
status=ExecutionStatus.FAILED,
|
|
@@ -959,3 +1010,52 @@ class GatewayClient:
|
|
|
959
1010
|
logger.warning(
|
|
960
1011
|
"Failed to record client dispatch span: %s", err, exc_info=True
|
|
961
1012
|
)
|
|
1013
|
+
|
|
1014
|
+
async def _write_trace_root_start(
|
|
1015
|
+
self,
|
|
1016
|
+
*,
|
|
1017
|
+
trace_id: str,
|
|
1018
|
+
message_id: str,
|
|
1019
|
+
session_id: str,
|
|
1020
|
+
target_agent_type: str,
|
|
1021
|
+
content: Any,
|
|
1022
|
+
start_ts: int,
|
|
1023
|
+
) -> None:
|
|
1024
|
+
"""Write trace root start — best-effort, never propagates errors."""
|
|
1025
|
+
try:
|
|
1026
|
+
await self._trace_writer.record_trace(
|
|
1027
|
+
TraceRecord(
|
|
1028
|
+
trace_id=trace_id,
|
|
1029
|
+
name=target_agent_type,
|
|
1030
|
+
session_id=session_id,
|
|
1031
|
+
root_message_id=message_id,
|
|
1032
|
+
root_agent_type=target_agent_type,
|
|
1033
|
+
input=sanitize_io_value(content, self._obs_config),
|
|
1034
|
+
status="QUEUED",
|
|
1035
|
+
start_ts=start_ts,
|
|
1036
|
+
)
|
|
1037
|
+
)
|
|
1038
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
1039
|
+
pass
|
|
1040
|
+
|
|
1041
|
+
async def _write_trace_root_end(
|
|
1042
|
+
self,
|
|
1043
|
+
*,
|
|
1044
|
+
trace_id: str,
|
|
1045
|
+
status: str,
|
|
1046
|
+
end_ts: int,
|
|
1047
|
+
error: str = "",
|
|
1048
|
+
) -> None:
|
|
1049
|
+
"""Write trace root end on routing failure — best-effort."""
|
|
1050
|
+
try:
|
|
1051
|
+
meta: dict = {"error": error} if error else {}
|
|
1052
|
+
await self._trace_writer.record_trace(
|
|
1053
|
+
TraceRecord(
|
|
1054
|
+
trace_id=trace_id,
|
|
1055
|
+
status=status,
|
|
1056
|
+
end_ts=end_ts,
|
|
1057
|
+
metadata=meta,
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
1061
|
+
pass
|