@oswaldzsh/devhive 0.1.0

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 (46) hide show
  1. package/README.md +91 -0
  2. package/__init__.py +0 -0
  3. package/agents/__init__.py +0 -0
  4. package/agents/base.py +118 -0
  5. package/agents/execute.py +150 -0
  6. package/agents/verifier_dynamic.py +164 -0
  7. package/agents/verifier_semantic.py +84 -0
  8. package/agents/verifier_static.py +153 -0
  9. package/bin/dh +77 -0
  10. package/config.yaml +71 -0
  11. package/control_plane/__init__.py +0 -0
  12. package/control_plane/cli.py +596 -0
  13. package/control_plane/dashboard.py +57 -0
  14. package/control_plane/notifications.py +54 -0
  15. package/control_plane/tui.py +352 -0
  16. package/install.sh +67 -0
  17. package/orchestrator/__init__.py +0 -0
  18. package/orchestrator/agent_pool.py +107 -0
  19. package/orchestrator/convergence_gate.py +133 -0
  20. package/orchestrator/engine.py +353 -0
  21. package/orchestrator/event_bus.py +58 -0
  22. package/orchestrator/task_queue.py +59 -0
  23. package/package.json +50 -0
  24. package/protocol/__init__.py +0 -0
  25. package/protocol/schemas.py +222 -0
  26. package/setup.py +44 -0
  27. package/signature/__init__.py +0 -0
  28. package/signature/engine.py +211 -0
  29. package/signature/extractor.py +156 -0
  30. package/signature/learner.py +75 -0
  31. package/signature/src/matcher.c +263 -0
  32. package/signature/src/matcher.h +135 -0
  33. package/signatures/seed_signatures.json +174 -0
  34. package/storage/__init__.py +0 -0
  35. package/storage/checkpoint.py +153 -0
  36. package/storage/signature_db.py +62 -0
  37. package/tools/__init__.py +0 -0
  38. package/tools/api_client.py +101 -0
  39. package/tools/git.py +75 -0
  40. package/tools/sandbox.py +79 -0
  41. package/verification/__init__.py +0 -0
  42. package/verification/diagnostic.py +124 -0
  43. package/verification/patterns/api_breaking.yaml +25 -0
  44. package/verification/patterns/code_quality.yaml +41 -0
  45. package/verification/patterns/security.yaml +41 -0
  46. package/verification/pipeline.py +61 -0
@@ -0,0 +1,54 @@
1
+ """Desktop notification helper for DevHive."""
2
+
3
+ import subprocess
4
+ import platform
5
+ from typing import Optional
6
+
7
+
8
+ def notify(title: str, message: str, urgency: str = "normal") -> bool:
9
+ """Send a desktop notification. Returns True if successful."""
10
+ system = platform.system()
11
+
12
+ try:
13
+ if system == "Linux":
14
+ subprocess.run(
15
+ ["notify-send", "-u", urgency, title, message],
16
+ timeout=5, capture_output=True,
17
+ )
18
+ return True
19
+ elif system == "Darwin":
20
+ subprocess.run(
21
+ ["osascript", "-e",
22
+ f'display notification "{message}" with title "{title}"'],
23
+ timeout=5, capture_output=True,
24
+ )
25
+ return True
26
+ elif system == "Windows":
27
+ # Windows toast notification via PowerShell
28
+ subprocess.run(
29
+ ["powershell", "-Command",
30
+ f'New-BurntToastNotification -Text "{title}", "{message}"'],
31
+ timeout=5, capture_output=True,
32
+ )
33
+ return True
34
+ except Exception:
35
+ pass
36
+
37
+ return False
38
+
39
+
40
+ def terminal_bell():
41
+ """Ring the terminal bell."""
42
+ print("\a", end="", flush=True)
43
+
44
+
45
+ def escalate(task_id: str, reason: str):
46
+ """Send escalation notification via all available channels."""
47
+ title = f"DevHive: {task_id}"
48
+ message = f"Needs attention: {reason}"
49
+ notify(title, message, urgency="critical")
50
+ terminal_bell()
51
+ print(f"\n{'!'*60}")
52
+ print(f" ESCALATION: {task_id}")
53
+ print(f" {reason}")
54
+ print(f"{'!'*60}\n")
@@ -0,0 +1,352 @@
1
+ """
2
+ DevHive TUI — Rich terminal UI components.
3
+
4
+ Layout:
5
+ ┌─ Header ───────────────────────────────────────────────┐
6
+ │ Model: xxx | Uptime: 12m | Tasks: 3 done / 5 total │
7
+ ├─ Pipeline ───────┬─ Detail ────────────────────────────┤
8
+ │ ● SPECIFY │ Task: fix-login-timeout │
9
+ │ ● EXECUTE │ Stage: VERIFY_L1 │
10
+ │ ◐ VERIFY_L1 │ Static ✓ Dynamic ◐ running │
11
+ │ ○ VERIFY_L2 │ Age: 45s │
12
+ │ ○ MERGE │ │
13
+ ├─ Activity ───────┴─────────────────────────────────────┤
14
+ │ [14:30:01] execute-1 Started task-abc123 │
15
+ │ [14:30:15] static-v PASS - No issues found │
16
+ ├─ Input ────────────────────────────────────────────────┤
17
+ │ :help :quit :do :log :review :status │
18
+ └────────────────────────────────────────────────────────┘
19
+ """
20
+
21
+ import time
22
+ import asyncio
23
+ from datetime import datetime, timezone
24
+ from typing import Optional
25
+
26
+ from rich.console import Console, RenderableType
27
+ from rich.live import Live
28
+ from rich.layout import Layout
29
+ from rich.panel import Panel
30
+ from rich.table import Table
31
+ from rich.text import Text
32
+ from rich.align import Align
33
+ from rich.spinner import Spinner
34
+ from rich.columns import Columns
35
+ from rich import box
36
+
37
+
38
+ STAGE_ORDER = ["SPECIFY", "EXECUTE", "VERIFY_L1", "VERIFY_L2", "MERGE"]
39
+ STAGE_COLORS = {
40
+ "SPECIFY": "dim cyan",
41
+ "EXECUTE": "bright_yellow",
42
+ "VERIFY_L1": "bright_magenta",
43
+ "VERIFY_L2": "bright_blue",
44
+ "MERGE": "bright_green",
45
+ }
46
+ STAGE_SPINNERS = {
47
+ "SPECIFY": "dots",
48
+ "EXECUTE": "bouncingBar",
49
+ "VERIFY_L1": "arc",
50
+ "VERIFY_L2": "arc",
51
+ "MERGE": "dots",
52
+ }
53
+
54
+
55
+ class DevHiveTUI:
56
+ """Rich-based terminal UI for DevHive."""
57
+
58
+ def __init__(self, orchestrator=None):
59
+ self.console = Console()
60
+ self._orchestrator = orchestrator
61
+ self._start_time = time.monotonic()
62
+ self._activity: list[tuple[str, str, str]] = [] # (time, source, message)
63
+ self._tasks: dict = {}
64
+ self._selected_task: Optional[str] = None
65
+ self._running = False
66
+
67
+ def log_activity(self, source: str, message: str):
68
+ """Add an entry to the activity feed."""
69
+ ts = datetime.now(timezone.utc).replace(tzinfo=None).strftime("%H:%M:%S")
70
+ self._activity.append((ts, source, message))
71
+ if len(self._activity) > 100:
72
+ self._activity = self._activity[-50:]
73
+
74
+ def update_task(self, task_id: str, stage: str, status: str = "", detail: str = ""):
75
+ self._tasks[task_id] = {"stage": stage, "status": status, "detail": detail,
76
+ "updated": time.monotonic()}
77
+
78
+ def set_tasks(self, tasks: dict):
79
+ self._tasks = tasks
80
+
81
+ @property
82
+ def uptime(self) -> str:
83
+ seconds = int(time.monotonic() - self._start_time)
84
+ if seconds < 60:
85
+ return f"{seconds}s"
86
+ elif seconds < 3600:
87
+ return f"{seconds // 60}m {seconds % 60}s"
88
+ else:
89
+ h = seconds // 3600
90
+ m = (seconds % 3600) // 60
91
+ return f"{h}h {m}m"
92
+
93
+ def make_layout(self) -> Layout:
94
+ """Build the main layout tree."""
95
+ layout = Layout()
96
+ layout.split(
97
+ Layout(name="header", size=3),
98
+ Layout(name="main"),
99
+ Layout(name="input", size=3),
100
+ )
101
+ layout["main"].split_row(
102
+ Layout(name="pipeline", ratio=1),
103
+ Layout(name="detail", ratio=2),
104
+ )
105
+ return layout
106
+
107
+ def _header(self) -> Panel:
108
+ model = "deepseek-v4-pro"
109
+ task_count = len(self._tasks)
110
+ active_count = sum(1 for t in self._tasks.values()
111
+ if t.get("stage") not in ("MERGE", ""))
112
+ escalations = sum(1 for t in self._tasks.values()
113
+ if t.get("status") == "ESCALATED")
114
+
115
+ grid = Table.grid(expand=True)
116
+ grid.add_column(justify="left")
117
+ grid.add_column(justify="right")
118
+
119
+ left = Text()
120
+ left.append("⚙ DevHive", style="bold white")
121
+ left.append(f" │ Model: {model}", style="dim")
122
+ left.append(f" │ Uptime: {self.uptime}", style="dim")
123
+
124
+ right = Text()
125
+ right.append(f"Tasks: {active_count} active", style="bright_cyan")
126
+ right.append(f" │ {task_count - active_count} done", style="dim green")
127
+ if escalations:
128
+ right.append(f" │ {escalations} escalated", style="bright_red")
129
+
130
+ grid.add_row(left, right)
131
+ return Panel(grid, style="bold", border_style="bright_black")
132
+
133
+ def _pipeline_panel(self) -> Panel:
134
+ """Render the pipeline stage list with spinners."""
135
+ table = Table.grid(padding=(0, 1))
136
+ table.add_column(style="bold")
137
+
138
+ active_stages = set()
139
+ for t in self._tasks.values():
140
+ stage = t.get("stage", "")
141
+ if stage in STAGE_ORDER:
142
+ active_stages.add(stage)
143
+
144
+ for stage in STAGE_ORDER:
145
+ style = STAGE_COLORS.get(stage, "")
146
+ if stage in active_stages:
147
+ indicator = Spinner(STAGE_SPINNERS.get(stage, "dots"), text="",
148
+ style=style)
149
+ else:
150
+ indicator = Text("○", style="dim")
151
+ label = Text(f" {stage.replace('_', ' ')}", style=style if stage in active_stages else "dim")
152
+ table.add_row(Align.left(indicator), Align.left(label))
153
+
154
+ for _ in range(len(STAGE_ORDER), 7):
155
+ table.add_row(Text(""))
156
+
157
+ return Panel(table, title="Pipeline", border_style="bright_black",
158
+ title_align="left")
159
+
160
+ def _detail_panel(self) -> Panel:
161
+ """Detail view for selected or most recent task."""
162
+ if self._selected_task and self._selected_task in self._tasks:
163
+ tasks_to_show = {self._selected_task: self._tasks[self._selected_task]}
164
+ elif self._tasks:
165
+ # Show most recently updated tasks
166
+ sorted_tasks = sorted(self._tasks.items(),
167
+ key=lambda x: x[1].get("updated", 0), reverse=True)
168
+ tasks_to_show = dict(sorted_tasks[:5])
169
+ else:
170
+ tasks_to_show = {}
171
+
172
+ if not tasks_to_show:
173
+ return Panel(
174
+ Align.center(Text("No tasks yet.\n\nType :do <description> to submit one.",
175
+ style="dim")),
176
+ title="Tasks", border_style="bright_black", title_align="left"
177
+ )
178
+
179
+ content = Table.grid(padding=(0, 1))
180
+ content.add_column(style="bold", width=30)
181
+ content.add_column()
182
+
183
+ for task_id, info in tasks_to_show.items():
184
+ stage = info.get("stage", "?")
185
+ status = info.get("status", "")
186
+ detail = info.get("detail", "")
187
+ style = STAGE_COLORS.get(stage, "white")
188
+
189
+ # Task header
190
+ short_id = task_id[-16:] if len(task_id) > 16 else task_id
191
+ stage_icon = "◐" if stage not in ("MERGE", "") else "✓"
192
+
193
+ row_text = Text()
194
+ row_text.append(f"{stage_icon} ", style=style)
195
+ row_text.append(short_id, style="bold")
196
+ content.add_row(row_text, Text(detail[:80] or stage, style="dim"))
197
+
198
+ # Stage and status
199
+ stage_status = Text()
200
+ stage_status.append(stage, style=style)
201
+ if status:
202
+ stage_status.append(f" {status}", style="dim")
203
+
204
+ content.add_row(Text(""), stage_status)
205
+ content.add_row(Text(""), Text("")) # spacer
206
+
207
+ return Panel(content, title="Tasks", border_style="bright_black",
208
+ title_align="left")
209
+
210
+ def _activity_panel(self) -> Panel:
211
+ """Activity feed showing recent events."""
212
+ recent = self._activity[-8:] if self._activity else []
213
+ if not recent:
214
+ return Panel(Text("Waiting for activity...", style="dim"),
215
+ title="Activity", border_style="bright_black",
216
+ title_align="left")
217
+
218
+ lines = []
219
+ for ts, source, msg in recent:
220
+ line = Text()
221
+ line.append(f"[{ts}] ", style="dim")
222
+ line.append(f"{source:<14}", style="bold cyan")
223
+ line.append(msg[:80], style="")
224
+ lines.append(line)
225
+
226
+ return Panel(Text("\n").join(lines),
227
+ title="Activity", border_style="bright_black",
228
+ title_align="left")
229
+
230
+ def _input_bar(self) -> Panel:
231
+ """Command input bar."""
232
+ hints = Text(":help :quit :do :status :review :log :resolve",
233
+ style="dim")
234
+ return Panel(hints, border_style="bright_black")
235
+
236
+ def render(self) -> Layout:
237
+ layout = self.make_layout()
238
+ layout["header"].update(self._header())
239
+ layout["pipeline"].update(self._pipeline_panel())
240
+ layout["detail"].update(self._detail_panel())
241
+ layout["input"].update(self._input_bar())
242
+ return layout
243
+
244
+
245
+ # ── Standalone display helpers (non-interactive) ─────────
246
+
247
+ def status_display(tasks: list[dict], escalations: list[dict]) -> RenderableType:
248
+ """One-shot status display (for `dh status`)."""
249
+ console = Console()
250
+
251
+ # Header
252
+ console.print()
253
+ console.print(Panel(Text("DevHive Status", style="bold white"),
254
+ border_style="bright_blue"))
255
+ console.print(f" Active tasks: {len(tasks)}")
256
+ console.print(f" Open escalations: {len(escalations)}")
257
+ console.print()
258
+
259
+ # Tasks table
260
+ if tasks:
261
+ table = Table(title="Tasks", box=box.ROUNDED, border_style="bright_black")
262
+ table.add_column("Task ID", style="dim")
263
+ table.add_column("Stage")
264
+ table.add_column("Status")
265
+
266
+ for t in tasks:
267
+ stage = t.get("current_stage", "?")
268
+ style = STAGE_COLORS.get(stage, "white")
269
+ table.add_row(
270
+ t.get("id", "")[-24:],
271
+ f"[{style}]{stage}[/]",
272
+ t.get("status", "pending"),
273
+ )
274
+ console.print(table)
275
+
276
+ # Escalations
277
+ if escalations:
278
+ console.print()
279
+ for e in escalations:
280
+ console.print(Panel(
281
+ f"Task: {e.get('task_id', '?')}\n"
282
+ f"Escalation ID: {e.get('id', '?')}\n"
283
+ f"Created: {e.get('created_at', '?')}",
284
+ title="⚠ Escalation", border_style="red"
285
+ ))
286
+
287
+ return ""
288
+
289
+
290
+ def task_timeline(checkpoints: list[dict]) -> RenderableType:
291
+ """Render a task's execution timeline."""
292
+ console = Console()
293
+
294
+ if not checkpoints:
295
+ return Panel(Text("No history found.", style="dim"),
296
+ title="Task Timeline", border_style="yellow")
297
+
298
+ console.print()
299
+ console.print(Panel(Text("Task Timeline", style="bold white"),
300
+ border_style="bright_blue"))
301
+ console.print()
302
+
303
+ for i, cp in enumerate(checkpoints):
304
+ stage = cp.get("stage", "?")
305
+ outcome = cp.get("outcome", "?")
306
+ created = cp.get("created_at", "?")
307
+ style = STAGE_COLORS.get(stage, "white")
308
+
309
+ icon = "✓" if outcome == "COMPLETED" or outcome == "PASS" else \
310
+ "✗" if outcome == "FAIL" else \
311
+ "⚠" if outcome == "WARN" else "●"
312
+
313
+ color = "green" if outcome in ("COMPLETED", "PASS") else \
314
+ "red" if outcome == "FAIL" else \
315
+ "yellow" if outcome == "WARN" else "dim"
316
+
317
+ connector = "├─" if i < len(checkpoints) - 1 else "└─"
318
+ console.print(f" {connector} [{color}]{icon} {stage}[/] [{style}]{outcome}[/] [dim]{created}[/]")
319
+
320
+ console.print()
321
+ return ""
322
+
323
+
324
+ def escalation_review(escalations: list[dict]) -> Optional[str]:
325
+ """Interactive escalation review. Returns selected escalation_id or None."""
326
+ console = Console()
327
+
328
+ if not escalations:
329
+ console.print(Panel(Text("No open escalations! 🎉", style="green"),
330
+ border_style="green"))
331
+ return None
332
+
333
+ console.print()
334
+ console.print(Panel(Text("Pending Escalations", style="bold white"),
335
+ border_style="bright_red"))
336
+
337
+ for i, e in enumerate(escalations):
338
+ console.print(f" [{i+1}] {e.get('id', '?')[:30]}")
339
+ console.print(f" Task: {e.get('task_id', '?')[-30:]}")
340
+ console.print(f" Created: {e.get('created_at', '?')}")
341
+ console.print()
342
+
343
+ try:
344
+ choice = input(f" Select [1-{len(escalations)}] or Enter to skip: ").strip()
345
+ if choice.isdigit():
346
+ idx = int(choice) - 1
347
+ if 0 <= idx < len(escalations):
348
+ return escalations[idx].get("id")
349
+ except (EOFError, KeyboardInterrupt):
350
+ pass
351
+
352
+ return None
package/install.sh ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bash
2
+ # DevHive post-install script
3
+ # Installs Python dependencies and links the module.
4
+
5
+ set -e
6
+
7
+ DEVHIVE_HOME="${DEVHIVE_HOME:-$HOME/.devhive}"
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+
10
+ echo ""
11
+ echo " ╔══════════════════════════════════╗"
12
+ echo " ║ DevHive v0.1.0 — Installing ║"
13
+ echo " ╚══════════════════════════════════╝"
14
+ echo ""
15
+
16
+ # 1. Find Python
17
+ PYTHON=""
18
+ for cmd in python3 python; do
19
+ if command -v "$cmd" &>/dev/null; then
20
+ PYTHON="$cmd"
21
+ break
22
+ fi
23
+ done
24
+
25
+ if [ -z "$PYTHON" ]; then
26
+ echo " ⚠ Python 3 not found. Please install Python 3.12+ first."
27
+ echo " ⚠ DevHive installed but Python components are not available."
28
+ exit 0
29
+ fi
30
+
31
+ echo " Python: $PYTHON ($($PYTHON --version))"
32
+
33
+ # 2. Install pip deps
34
+ echo " Installing Python dependencies..."
35
+ $PYTHON -m pip install --quiet httpx pydantic pyyaml rich 2>/dev/null || {
36
+ echo " ⚠ pip install failed. Please run manually:"
37
+ echo " pip3 install httpx pydantic pyyaml rich"
38
+ }
39
+
40
+ # 3. Link module
41
+ mkdir -p "$DEVHIVE_HOME"
42
+ if [ "$SCRIPT_DIR" != "$DEVHIVE_HOME" ]; then
43
+ echo " Linking module to $DEVHIVE_HOME..."
44
+ # Copy module files
45
+ for dir in control_plane agents orchestrator protocol verification signature storage tools; do
46
+ if [ -d "$SCRIPT_DIR/$dir" ]; then
47
+ cp -r "$SCRIPT_DIR/$dir" "$DEVHIVE_HOME/"
48
+ fi
49
+ done
50
+ # Copy root files
51
+ for f in __init__.py config.yaml setup.py; do
52
+ if [ -f "$SCRIPT_DIR/$f" ]; then
53
+ cp "$SCRIPT_DIR/$f" "$DEVHIVE_HOME/"
54
+ fi
55
+ done
56
+ fi
57
+
58
+ # 4. Verify installation
59
+ echo ""
60
+ echo " ✓ DevHive installed successfully!"
61
+ echo ""
62
+ echo " Quick start:"
63
+ echo " dh # Interactive REPL with live dashboard"
64
+ echo " dh do \"fix bug\" # Submit a task"
65
+ echo " dh status # Check system status"
66
+ echo " dh review # Review escalations"
67
+ echo ""
File without changes
@@ -0,0 +1,107 @@
1
+ """Agent Pool Manager — manages agent process lifecycle."""
2
+
3
+ import multiprocessing
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+ from protocol.schemas import Task
9
+
10
+
11
+ class AgentType(str, Enum):
12
+ EXECUTE = "execute"
13
+ STATIC_VERIFIER = "static_verifier"
14
+ DYNAMIC_VERIFIER = "dynamic_verifier"
15
+ SEMANTIC_VERIFIER = "semantic_verifier"
16
+
17
+
18
+ @dataclass
19
+ class AgentHandle:
20
+ agent_id: str
21
+ agent_type: AgentType
22
+ process: multiprocessing.Process
23
+ task_queue: multiprocessing.Queue
24
+ result_queue: multiprocessing.Queue
25
+ busy: bool = False
26
+ current_task_id: Optional[str] = None
27
+
28
+
29
+ class AgentPool:
30
+ """Manages a pool of agent processes with task dispatching."""
31
+
32
+ def __init__(self, config: dict = None):
33
+ self.config = config or {}
34
+ self.agents: dict[str, AgentHandle] = {}
35
+ self._next_id: dict[AgentType, int] = {}
36
+
37
+ def start_agent(self, agent_type: AgentType) -> AgentHandle:
38
+ """Start a new agent process."""
39
+ from agents.execute import ExecuteAgent
40
+ from agents.verifier_static import StaticVerifier
41
+ from agents.verifier_dynamic import DynamicVerifier
42
+ from agents.verifier_semantic import SemanticVerifier
43
+
44
+ idx = self._next_id.get(agent_type, 0) + 1
45
+ self._next_id[agent_type] = idx
46
+ agent_id = f"{agent_type.value}-{idx}"
47
+
48
+ task_queue = multiprocessing.Queue()
49
+ result_queue = multiprocessing.Queue()
50
+
51
+ agent_classes = {
52
+ AgentType.EXECUTE: ExecuteAgent,
53
+ AgentType.STATIC_VERIFIER: StaticVerifier,
54
+ AgentType.DYNAMIC_VERIFIER: DynamicVerifier,
55
+ AgentType.SEMANTIC_VERIFIER: SemanticVerifier,
56
+ }
57
+
58
+ agent_cls = agent_classes[agent_type]
59
+ agent = agent_cls(agent_id, task_queue, result_queue, self.config)
60
+ agent.start()
61
+
62
+ handle = AgentHandle(
63
+ agent_id=agent_id,
64
+ agent_type=agent_type,
65
+ process=agent,
66
+ task_queue=task_queue,
67
+ result_queue=result_queue,
68
+ )
69
+ self.agents[agent_id] = handle
70
+ return handle
71
+
72
+ def get_idle(self, agent_type: AgentType) -> Optional[AgentHandle]:
73
+ """Find an idle agent of the specified type."""
74
+ for handle in self.agents.values():
75
+ if handle.agent_type == agent_type and not handle.busy:
76
+ return handle
77
+ return None
78
+
79
+ def dispatch(self, handle: AgentHandle, task: Task):
80
+ """Send a task to an idle agent."""
81
+ handle.busy = True
82
+ handle.current_task_id = task.id
83
+ handle.task_queue.put(task.model_dump())
84
+
85
+ def mark_idle(self, agent_id: str):
86
+ """Mark an agent as idle after task completion."""
87
+ if agent_id in self.agents:
88
+ self.agents[agent_id].busy = False
89
+ self.agents[agent_id].current_task_id = None
90
+
91
+ def stop_all(self):
92
+ """Stop all agent processes."""
93
+ for handle in self.agents.values():
94
+ handle.task_queue.put(None) # poison pill
95
+ handle.process.join(timeout=10)
96
+ if handle.process.is_alive():
97
+ handle.process.terminate()
98
+
99
+ def idle_count(self, agent_type: AgentType) -> int:
100
+ """Count idle agents of a given type."""
101
+ return sum(1 for h in self.agents.values()
102
+ if h.agent_type == agent_type and not h.busy)
103
+
104
+ def total_count(self, agent_type: AgentType) -> int:
105
+ """Count total agents of a given type."""
106
+ return sum(1 for h in self.agents.values()
107
+ if h.agent_type == agent_type)