by-framework 0.2.2.dev5__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/common/__init__.py +8 -1
- by_framework/common/constants.py +23 -0
- by_framework/common/logger.py +23 -10
- by_framework/common/redis_client.py +13 -0
- by_framework/core/extensions/__init__.py +2 -0
- by_framework/core/extensions/agent_config_audit.py +144 -0
- by_framework/core/protocol/__init__.py +6 -0
- by_framework/core/protocol/action_type.py +11 -1
- by_framework/core/protocol/commands.py +80 -1
- by_framework/core/registry.py +140 -2
- by_framework/metrics/__init__.py +105 -0
- by_framework/metrics/catalog.py +409 -0
- by_framework/metrics/collector.py +70 -3
- by_framework/metrics/read_client.py +243 -0
- by_framework/metrics/snapshot.py +536 -43
- by_framework/util/discovery_http_client.py +4 -3
- by_framework/worker/_control_handling.py +47 -12
- by_framework/worker/context.py +2 -0
- by_framework/worker/heartbeat.py +80 -5
- by_framework/worker/runner.py +161 -7
- by_framework/worker/worker.py +19 -2
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev6.dist-info}/METADATA +6 -1
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev6.dist-info}/RECORD +30 -24
- by_framework-0.2.2.dev6.dist-info/entry_points.txt +2 -0
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev6.dist-info}/WHEEL +0 -0
- {by_framework-0.2.2.dev5.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/common/__init__.py
CHANGED
|
@@ -28,7 +28,13 @@ from .config import (
|
|
|
28
28
|
from .constants import RedisKeys
|
|
29
29
|
from .emitter import (DataLayoutBuilder, DefaultSseLayoutBuilder, GatewayDataEmitter)
|
|
30
30
|
from .logger import get_logger, logger, setup_logging
|
|
31
|
-
from .redis_client import
|
|
31
|
+
from .redis_client import (
|
|
32
|
+
Redis,
|
|
33
|
+
close_redis,
|
|
34
|
+
get_redis,
|
|
35
|
+
init_redis,
|
|
36
|
+
init_redis_from_url,
|
|
37
|
+
)
|
|
32
38
|
|
|
33
39
|
__all__ = [
|
|
34
40
|
"RedisKeys",
|
|
@@ -37,6 +43,7 @@ __all__ = [
|
|
|
37
43
|
"setup_logging",
|
|
38
44
|
"get_redis",
|
|
39
45
|
"init_redis",
|
|
46
|
+
"init_redis_from_url",
|
|
40
47
|
"close_redis",
|
|
41
48
|
"Redis",
|
|
42
49
|
"DataLayoutBuilder",
|
by_framework/common/constants.py
CHANGED
|
@@ -135,6 +135,9 @@ class RedisKeys:
|
|
|
135
135
|
# --- Registry ---
|
|
136
136
|
# Set of known workers used for registry enumeration.
|
|
137
137
|
KNOWN_WORKERS = "byai_gateway:registry:workers"
|
|
138
|
+
# Set of workers with explicit admin lifecycle state. Used by dashboard
|
|
139
|
+
# management views to include offline suspended/evicted workers.
|
|
140
|
+
ADMIN_WORKERS = "byai_gateway:registry:worker:admin_workers"
|
|
138
141
|
WORKER_DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 5
|
|
139
142
|
# 6× the heartbeat interval gives enough margin even when the main event
|
|
140
143
|
# loop is briefly occupied by an LLM call. The dedicated heartbeat thread
|
|
@@ -196,6 +199,26 @@ class RedisKeys:
|
|
|
196
199
|
"""HASH mapping worker execution IDs to lightweight history snapshots."""
|
|
197
200
|
return f"byai_gateway:registry:worker:history_snapshots:{worker_id}"
|
|
198
201
|
|
|
202
|
+
@staticmethod
|
|
203
|
+
def worker_admin(worker_id: str) -> str:
|
|
204
|
+
"""HASH storing admin-controlled state for a Worker.
|
|
205
|
+
|
|
206
|
+
Fields: lifecycle (active|suspended|evicted), reason, updated_at.
|
|
207
|
+
Written by WorkerManager; read by the worker on heartbeat and startup.
|
|
208
|
+
No TTL — persists until explicitly cleared by an admin action.
|
|
209
|
+
"""
|
|
210
|
+
return f"byai_gateway:registry:worker:admin:{worker_id}"
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def agent_type_denied(agent_type: str) -> str:
|
|
214
|
+
"""SET of worker_ids explicitly denied from consuming an agent_type stream.
|
|
215
|
+
|
|
216
|
+
Key absent or empty SET means the agent_type is open to all workers.
|
|
217
|
+
Written by WorkerManager; checked by workers before XREADGROUP and
|
|
218
|
+
inside register_worker_membership().
|
|
219
|
+
"""
|
|
220
|
+
return f"byai_gateway:registry:agent_type:denied:{agent_type}"
|
|
221
|
+
|
|
199
222
|
@staticmethod
|
|
200
223
|
def session_registry(session_id: str) -> str:
|
|
201
224
|
"""Session-level aggregate registry (Hash).
|
by_framework/common/logger.py
CHANGED
|
@@ -6,6 +6,28 @@ import sys
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from logging.handlers import RotatingFileHandler
|
|
8
8
|
|
|
9
|
+
OBSERVABILITY_LOG_FIELDS = (
|
|
10
|
+
"worker_id",
|
|
11
|
+
"message_id",
|
|
12
|
+
"session_id",
|
|
13
|
+
"trace_id",
|
|
14
|
+
"execution_id",
|
|
15
|
+
"agent_type",
|
|
16
|
+
"task_group_id",
|
|
17
|
+
"span_id",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def observability_log_extra(**fields: object) -> dict[str, dict[str, str]]:
|
|
22
|
+
"""Build a logging ``extra`` payload with stable correlation field names."""
|
|
23
|
+
return {
|
|
24
|
+
"extra": {
|
|
25
|
+
key: str(value)
|
|
26
|
+
for key, value in fields.items()
|
|
27
|
+
if key in OBSERVABILITY_LOG_FIELDS and value not in ("", None)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
|
|
10
32
|
class ContextFilter(logging.Filter):
|
|
11
33
|
"""
|
|
@@ -80,16 +102,7 @@ class JSONFormatter(logging.Formatter):
|
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
# Add extra fields if present and non-empty
|
|
83
|
-
for key in
|
|
84
|
-
"worker_id",
|
|
85
|
-
"message_id",
|
|
86
|
-
"session_id",
|
|
87
|
-
"trace_id",
|
|
88
|
-
"execution_id",
|
|
89
|
-
"agent_type",
|
|
90
|
-
"task_group_id",
|
|
91
|
-
"span_id",
|
|
92
|
-
):
|
|
105
|
+
for key in OBSERVABILITY_LOG_FIELDS:
|
|
93
106
|
val = getattr(record, key, None)
|
|
94
107
|
if val:
|
|
95
108
|
log_data[key] = val
|
|
@@ -56,6 +56,19 @@ def init_redis(
|
|
|
56
56
|
return _redis_client
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def init_redis_from_url(url: str) -> Redis:
|
|
60
|
+
"""Initialize the global Redis singleton from a redis:// or rediss:// URL."""
|
|
61
|
+
global _redis_client # pylint: disable=global-statement
|
|
62
|
+
if _redis_client is None:
|
|
63
|
+
try:
|
|
64
|
+
_redis_client = Redis.from_url(url, decode_responses=True)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise RedisConnectionError(
|
|
67
|
+
f"Failed to initialize Redis from URL: {e}"
|
|
68
|
+
) from e
|
|
69
|
+
return _redis_client
|
|
70
|
+
|
|
71
|
+
|
|
59
72
|
def get_redis() -> Redis:
|
|
60
73
|
"""Get the initialized global Redis client.
|
|
61
74
|
|
|
@@ -8,6 +8,7 @@ managed through the form of plugins.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from .agent_config import AgentConfig, CallbackType
|
|
11
|
+
from .agent_config_audit import build_agent_config_audit_projection
|
|
11
12
|
from .plugin import (
|
|
12
13
|
AgentConfigsSnapshot,
|
|
13
14
|
Plugin,
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"AgentConfig",
|
|
25
26
|
"AgentConfigsSnapshot",
|
|
26
27
|
"CallbackType",
|
|
28
|
+
"build_agent_config_audit_projection",
|
|
27
29
|
"PluginManifest",
|
|
28
30
|
"Plugin",
|
|
29
31
|
"PluginBuildContext",
|