by-framework 0.2.2.dev5__py3-none-any.whl → 0.2.2.dev7__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/config.py +15 -1
- by_framework/common/constants.py +367 -98
- by_framework/common/emitter.py +10 -0
- by_framework/common/logger.py +23 -10
- by_framework/common/redis_client.py +62 -9
- by_framework/core/discovery.py +1 -1
- 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/protocol/content_type.py +1 -0
- by_framework/core/registry.py +162 -7
- 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 +563 -74
- by_framework/trace/span_recorder.py +40 -25
- by_framework/trace/trace_writer.py +34 -20
- by_framework/util/discovery_http_client.py +4 -3
- by_framework/worker/_control_handling.py +47 -12
- by_framework/worker/app.py +56 -14
- by_framework/worker/context.py +6 -0
- by_framework/worker/heartbeat.py +106 -5
- by_framework/worker/runner.py +203 -10
- by_framework/worker/worker.py +19 -2
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/METADATA +6 -1
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/RECORD +37 -31
- by_framework-0.2.2.dev7.dist-info/entry_points.txt +2 -0
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.dist-info}/WHEEL +0 -0
- {by_framework-0.2.2.dev5.dist-info → by_framework-0.2.2.dev7.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/config.py
CHANGED
|
@@ -6,7 +6,7 @@ Provides typed configuration loaded from environment variables.
|
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Optional
|
|
9
|
+
from typing import Literal, Optional
|
|
10
10
|
|
|
11
11
|
from by_framework.common.constants import RedisKeys
|
|
12
12
|
|
|
@@ -22,6 +22,10 @@ class RedisConfig:
|
|
|
22
22
|
username: Optional[str] = None
|
|
23
23
|
decode_responses: bool = True
|
|
24
24
|
max_connections: Optional[int] = None
|
|
25
|
+
mode: Literal["standalone", "cluster"] = "standalone"
|
|
26
|
+
cluster_nodes: Optional[list[tuple[str, int]]] = None
|
|
27
|
+
socket_connect_timeout: int = 5
|
|
28
|
+
socket_timeout: int = 10
|
|
25
29
|
|
|
26
30
|
@classmethod
|
|
27
31
|
def from_env(cls) -> "RedisConfig":
|
|
@@ -29,6 +33,14 @@ class RedisConfig:
|
|
|
29
33
|
password = os.environ.get("REDIS_PASSWORD", "")
|
|
30
34
|
username = os.environ.get("REDIS_USERNAME") or None
|
|
31
35
|
max_connections = os.environ.get("REDIS_MAX_CONNECTIONS")
|
|
36
|
+
mode = os.environ.get("REDIS_MODE", "standalone")
|
|
37
|
+
cluster_nodes_str = os.environ.get("REDIS_CLUSTER_NODES")
|
|
38
|
+
cluster_nodes = None
|
|
39
|
+
if cluster_nodes_str:
|
|
40
|
+
cluster_nodes = []
|
|
41
|
+
for node in cluster_nodes_str.split(","):
|
|
42
|
+
node_host, node_port = node.rsplit(":", 1)
|
|
43
|
+
cluster_nodes.append((node_host, int(node_port)))
|
|
32
44
|
return cls(
|
|
33
45
|
host=os.environ.get("REDIS_HOST", "localhost"),
|
|
34
46
|
port=int(os.environ.get("REDIS_PORT", "6379")),
|
|
@@ -36,6 +48,8 @@ class RedisConfig:
|
|
|
36
48
|
password=password,
|
|
37
49
|
username=username,
|
|
38
50
|
max_connections=int(max_connections) if max_connections else None,
|
|
51
|
+
mode=mode,
|
|
52
|
+
cluster_nodes=cluster_nodes,
|
|
39
53
|
)
|
|
40
54
|
|
|
41
55
|
|