From b2a0dec79ba7e465b014cd51a3c606763770cb89 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 14 Jan 2026 11:35:33 -0300 Subject: [PATCH] Add load-aware cockpit queue dispatcher - New CockpitQueueDispatcher: per-project serialized task queues - LoadMonitor: checks system load/memory before dispatching - Parallel execution across projects with round-robin fairness - CLI commands: cockpit queue, cockpit dispatch Co-Authored-By: Claude Opus 4.5 --- lib/__pycache__/cockpit.cpython-310.pyc | Bin 25934 -> 29572 bytes .../cockpit_queue_dispatcher.cpython-310.pyc | Bin 0 -> 10460 bytes lib/cockpit.py | 114 +++++ lib/cockpit_queue_dispatcher.py | 427 ++++++++++++++++++ ..._integrations.cpython-310-pytest-9.0.2.pyc | Bin 0 -> 37794 bytes 5 files changed, 541 insertions(+) create mode 100644 lib/__pycache__/cockpit_queue_dispatcher.cpython-310.pyc create mode 100644 lib/cockpit_queue_dispatcher.py create mode 100644 tests/__pycache__/test_integrations.cpython-310-pytest-9.0.2.pyc diff --git a/lib/__pycache__/cockpit.cpython-310.pyc b/lib/__pycache__/cockpit.cpython-310.pyc index 90774eb716f339468e9771d1ea12ff8ea87225ec..fecb15c33cac97ba1a4c0d6abee6443f4788bb17 100644 GIT binary patch delta 11504 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%0A}GVhKK{(dC5?PfegrZ zf&9}T{}J?9R3Wdq{zCzJgs9{UlGQ7(y2?luVF*-c`Zob_9yq$W_p4(%&=3rZR=E^5C9{t3NNxPg(6jl zrAB`vn}$i(teI^kBVpU-DPTsEaQjqH_E?DCTGr35o2tc118fKSNk&j!gR*3%Q0p>N z2$q?7NWJ4_xrse&r(E(*ELkgBtFfwGa#gfzgB5|+0l&Mniamw8irDBJ@UZLU6kndT zdB~9V|K8GwedQV5`PkcJ|9kD)lL($ms?zwKHuitr@p>SL^B%VUL22i|Wxq>(Utu%V z+YKm zEI$-+<%zcN+hhCT6y#OnCWJjEJ{O2j%xt;URMmwu=WhE`wV1!oy|9+PC&1l&?#L>Ch8@TFiK0K5kHcFS8&80NJu(6rKR^Sc8onPL3=nj2p5~3)x5cxIb2_liwg^5O% z{tn}MP)S(enaz6Ka__4yi^y+!TMsJ2ABcC<~r65Z*eXe}R+ z)uVdTDh$G>V8a(wV>fcvpP|OKNF-wHkl(Qx2W2p708@&F#L1%r>Wku;qdDJcbml)2 zD`Mw5-iP9=N1op+Z{GP8M7b!-MzuQfOG*-wsSarj zc)wB7U0L*bf4Rhemk46zBXn)uyAx}r_$J?6R%$uzfrkGgZvGX(@k1-FTKO*tN%z)# z>&%2?F0GAfvwTQR!y{WVTOU9v`IY?-Nid zdioWWUMKKtB?hQbQKl>}P)S~Xo9SzmveiUgn|KqgNa)KjZ-6F>kTip{j5;+aQ&{CD zJnyC1&WX<*>xjybFHH5<2$YDU^cJf4o@R9H>X+wwB;xS)vJ}egM+j{Zb-kWmmAL<+ zCjMeEs5Qu{j;pgz4XX5kOEZ3ctr@i-%FPvAP}4N`(j7#qMMtW|k5(Vbbn!-p<*f!? zSNV0I6IOTg9JoX-LEZ=cU6x1Om)V1SlGZ~4Uf^gM%e@#rO}LE&4infbT3Mv%EOkG~ z#6lR}#nK0vgYLI-$pmoaZ#Q`j4e=9riNGHdcsGIj032^>f?mbt32}s-bI*gTbH%_KkKvEKO-b2izKJ$r!e{(9s za!GR-0~G5{07$%}tQdOM;ai0|PcY;`;-sj1b&v3yi<^R)8oH>d8h*ix4PmWA^qal^ E1A3l_zW@LL delta 7984 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 diff --git a/lib/__pycache__/cockpit_queue_dispatcher.cpython-310.pyc b/lib/__pycache__/cockpit_queue_dispatcher.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3ac014a2668b0d415676d3390829d43633559b2 GIT binary patch literal 10460 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 literal 0 HcmV?d00001 diff --git a/lib/cockpit.py b/lib/cockpit.py index ed6fb20..13fd461 100644 --- a/lib/cockpit.py +++ b/lib/cockpit.py @@ -695,6 +695,71 @@ def cockpit_attach_cmd(project: str) -> str: return f"docker exec -it {container_name} tmux attach-session -t agent" +def cockpit_queue_task(project: str, task: str, context: str = "", + priority: str = "normal") -> Dict: + """ + Queue a task for background dispatch. + + Tasks are queued per-project and dispatched serially within each project, + but in parallel across projects (with load awareness). + + Args: + project: Target project name + task: Task description + context: Project context + priority: "high" or "normal" + + Returns: {"success": bool, "task_id": str, "message": str} + """ + try: + from cockpit_queue_dispatcher import CockpitQueueDispatcher + import yaml + + config_path = Path("/opt/server-agents/orchestrator/config/luzia.yaml") + if config_path.exists(): + config = yaml.safe_load(config_path.read_text()) + else: + config = {"projects": {}} + + dispatcher = CockpitQueueDispatcher(config) + task_id = dispatcher.enqueue_task(project, task, context, priority) + + return { + "success": True, + "task_id": task_id, + "message": f"Task queued for {project}", + "queue_position": len(dispatcher.get_pending_tasks(project)) + } + except ImportError: + return {"success": False, "message": "Queue dispatcher not available"} + except Exception as e: + return {"success": False, "message": str(e)} + + +def cockpit_queue_status() -> Dict: + """ + Get status of the task queue and dispatcher. + + Returns: {"success": bool, "status": dict} + """ + try: + from cockpit_queue_dispatcher import CockpitQueueDispatcher + import yaml + + config_path = Path("/opt/server-agents/orchestrator/config/luzia.yaml") + if config_path.exists(): + config = yaml.safe_load(config_path.read_text()) + else: + config = {"projects": {}} + + dispatcher = CockpitQueueDispatcher(config) + return {"success": True, "status": dispatcher.get_status()} + except ImportError: + return {"success": False, "message": "Queue dispatcher not available"} + except Exception as e: + return {"success": False, "message": str(e)} + + def cockpit_dispatch_task(project: str, task: str, context: str, config: dict, show_output: bool = True, timeout: int = 600) -> Dict: """ @@ -1057,6 +1122,11 @@ def route_cockpit(config: dict, args: list, kwargs: dict) -> int: print(" output Get recent output") print(" status [project] Show cockpit status") print(" attach Show attach command") + print("") + print("Queue commands (per-project serialized, parallel across projects):") + print(" queue Queue task for background dispatch") + print(" queue --status Show dispatcher status") + print(" dispatch Run one dispatch cycle") return 0 subcommand = args[0] @@ -1161,5 +1231,49 @@ def route_cockpit(config: dict, args: list, kwargs: dict) -> int: print(f" {cmd}") return 0 + if subcommand == "queue": + if len(subargs) < 2: + print("Usage: luzia cockpit queue ") + print(" luzia cockpit queue --status") + return 1 + if subargs[0] == "--status": + result = cockpit_queue_status() + if result["success"]: + print(json.dumps(result["status"], indent=2)) + return 0 + print(f"Error: {result['message']}") + return 1 + + project = subargs[0] + task = " ".join(subargs[1:]) + result = cockpit_queue_task(project, task) + if result["success"]: + print(f"OK: {result['message']}") + print(f" Task ID: {result['task_id']}") + print(f" Queue position: {result.get('queue_position', 'unknown')}") + return 0 + print(f"Error: {result['message']}") + return 1 + + if subcommand == "dispatch": + # Run one dispatch cycle + try: + from cockpit_queue_dispatcher import CockpitQueueDispatcher + import yaml + + config_path = Path("/opt/server-agents/orchestrator/config/luzia.yaml") + if config_path.exists(): + cfg = yaml.safe_load(config_path.read_text()) + else: + cfg = config + + dispatcher = CockpitQueueDispatcher(cfg) + result = dispatcher.run_dispatch_cycle() + print(json.dumps(result, indent=2)) + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + print(f"Unknown subcommand: {subcommand}") return 1 diff --git a/lib/cockpit_queue_dispatcher.py b/lib/cockpit_queue_dispatcher.py new file mode 100644 index 0000000..2acbe7f --- /dev/null +++ b/lib/cockpit_queue_dispatcher.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +""" +Cockpit Queue Dispatcher - Load-aware background task dispatcher + +Integrates: +- ProjectQueueScheduler: Per-project sequential, cross-project parallel +- Cockpit: Docker-based Claude sessions +- Load monitoring: Check system resources before dispatching + +Architecture: + TaskQueue (per-project) + ↓ + CockpitQueueDispatcher + ├─ Check system load + ├─ Select next task (round-robin) + └─ Dispatch to cockpit (non-blocking) +""" + +import json +import os +import psutil +import subprocess +import time +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Any +import threading +import queue + +# Import scheduler and cockpit +try: + from project_queue_scheduler import ProjectQueueScheduler + from cockpit import ( + cockpit_start, cockpit_dispatch_task, cockpit_status, + load_state, save_state, container_running, get_container_name + ) +except ImportError as e: + raise ImportError(f"Required module not found: {e}") + + +class LoadMonitor: + """Monitor system load for dispatch decisions.""" + + def __init__(self, max_load: float = 4.0, max_memory_pct: float = 85.0): + """ + Initialize load monitor. + + Args: + max_load: Maximum 1-minute load average (default: 4.0) + max_memory_pct: Maximum memory usage percent (default: 85%) + """ + self.max_load = max_load + self.max_memory_pct = max_memory_pct + + def can_dispatch(self) -> tuple[bool, str]: + """ + Check if system can handle new task dispatch. + + Returns: + Tuple of (can_dispatch: bool, reason: str) + """ + # Check load average + load1, load5, load15 = os.getloadavg() + if load1 > self.max_load: + return False, f"Load too high: {load1:.1f} (max: {self.max_load})" + + # Check memory + mem = psutil.virtual_memory() + if mem.percent > self.max_memory_pct: + return False, f"Memory too high: {mem.percent:.1f}% (max: {self.max_memory_pct}%)" + + return True, "OK" + + def get_stats(self) -> Dict[str, Any]: + """Get current system stats.""" + load1, load5, load15 = os.getloadavg() + mem = psutil.virtual_memory() + + return { + "load_1m": load1, + "load_5m": load5, + "load_15m": load15, + "memory_used_pct": mem.percent, + "memory_available_gb": mem.available / (1024**3), + "can_dispatch": self.can_dispatch()[0], + "timestamp": datetime.now().isoformat() + } + + +class CockpitQueueDispatcher: + """ + Background dispatcher for cockpit tasks. + + Features: + - Per-project task queues (serialized) + - Cross-project parallelism + - Load-aware dispatching + - Non-blocking dispatch with result tracking + """ + + STATE_DIR = Path("/var/lib/luz-orchestrator/dispatcher") + QUEUE_DIR = Path("/var/lib/luz-orchestrator/task_queue") + + def __init__(self, config: dict, max_concurrent_projects: int = 4): + """ + Initialize dispatcher. + + Args: + config: Luzia config dict + max_concurrent_projects: Max projects running simultaneously + """ + self.config = config + self.max_concurrent = max_concurrent_projects + self.scheduler = ProjectQueueScheduler() + self.load_monitor = LoadMonitor() + + # Ensure directories exist + self.STATE_DIR.mkdir(parents=True, exist_ok=True) + self.QUEUE_DIR.mkdir(parents=True, exist_ok=True) + + # In-memory task queues per project + self.project_queues: Dict[str, queue.Queue] = {} + + # Running dispatchers per project + self.running_projects: Dict[str, threading.Thread] = {} + + # Results storage + self.results: Dict[str, Dict] = {} + + def enqueue_task(self, project: str, task: str, context: str = "", + priority: str = "normal") -> str: + """ + Add task to project queue. + + Args: + project: Target project name + task: Task description + context: Project context + priority: "high" or "normal" + + Returns: + task_id for tracking + """ + task_id = datetime.now().strftime("%H%M%S") + "-" + hex(hash(task) & 0xffff)[2:] + + task_data = { + "id": task_id, + "project": project, + "task": task, + "context": context, + "priority": priority, + "queued_at": datetime.now().isoformat(), + "status": "pending" + } + + # Write to disk queue + queue_file = self.QUEUE_DIR / project / f"{task_id}.json" + queue_file.parent.mkdir(parents=True, exist_ok=True) + queue_file.write_text(json.dumps(task_data, indent=2)) + + # Also add to in-memory queue if exists + if project not in self.project_queues: + self.project_queues[project] = queue.Queue() + self.project_queues[project].put(task_data) + + return task_id + + def get_pending_tasks(self, project: str = None) -> List[Dict]: + """Get pending tasks, optionally filtered by project.""" + tasks = [] + + if project: + project_dir = self.QUEUE_DIR / project + if project_dir.exists(): + for task_file in sorted(project_dir.glob("*.json")): + try: + task = json.loads(task_file.read_text()) + if task.get("status") == "pending": + tasks.append(task) + except (json.JSONDecodeError, IOError): + pass + else: + # All projects + for project_dir in self.QUEUE_DIR.iterdir(): + if project_dir.is_dir(): + for task_file in sorted(project_dir.glob("*.json")): + try: + task = json.loads(task_file.read_text()) + if task.get("status") == "pending": + tasks.append(task) + except (json.JSONDecodeError, IOError): + pass + + return tasks + + def dispatch_task_async(self, project: str, task_data: Dict) -> threading.Thread: + """ + Dispatch a task to cockpit asynchronously. + + Args: + project: Project name + task_data: Task dict with id, task, context + + Returns: + Thread running the dispatch + """ + def _dispatch(): + task_id = task_data["id"] + task = task_data["task"] + context = task_data.get("context", "") + + try: + # Update task status + self._update_task_status(project, task_id, "running") + + # Dispatch to cockpit (this is blocking within the thread) + result = cockpit_dispatch_task( + project=project, + task=task, + context=context, + config=self.config, + show_output=False, # Don't print, we're async + timeout=600 + ) + + # Store result + self.results[task_id] = result + + # Update status based on result + if result.get("awaiting_response"): + self._update_task_status(project, task_id, "awaiting_human") + elif result.get("timed_out"): + self._update_task_status(project, task_id, "running") + else: + self._update_task_status(project, task_id, "completed") + # Release project slot + self.scheduler.release_task(project) + + except Exception as e: + self._update_task_status(project, task_id, "failed", str(e)) + self.scheduler.release_task(project) + + finally: + # Clean up running tracker + if project in self.running_projects: + del self.running_projects[project] + + thread = threading.Thread(target=_dispatch, daemon=True) + thread.start() + self.running_projects[project] = thread + + return thread + + def _update_task_status(self, project: str, task_id: str, + status: str, error: str = None) -> None: + """Update task status in queue file.""" + task_file = self.QUEUE_DIR / project / f"{task_id}.json" + if task_file.exists(): + try: + task = json.loads(task_file.read_text()) + task["status"] = status + task["updated_at"] = datetime.now().isoformat() + if error: + task["error"] = error + task_file.write_text(json.dumps(task, indent=2)) + except (json.JSONDecodeError, IOError): + pass + + def run_dispatch_cycle(self) -> Dict[str, Any]: + """ + Run one dispatch cycle. + + Checks load, selects available projects, dispatches tasks. + + Returns: + Dict with cycle results + """ + cycle_start = datetime.now() + dispatched = [] + skipped = [] + + # Check system load + can_dispatch, load_reason = self.load_monitor.can_dispatch() + if not can_dispatch: + return { + "cycle_time": cycle_start.isoformat(), + "dispatched": [], + "skipped": [], + "reason": f"Load check failed: {load_reason}" + } + + # Get current running projects + running_count = len(self.running_projects) + available_slots = self.max_concurrent - running_count + + if available_slots <= 0: + return { + "cycle_time": cycle_start.isoformat(), + "dispatched": [], + "skipped": [], + "reason": f"No slots available ({running_count}/{self.max_concurrent} running)" + } + + # Get pending tasks by project + pending = self.get_pending_tasks() + tasks_by_project: Dict[str, List[Dict]] = {} + for task in pending: + proj = task["project"] + if proj not in tasks_by_project: + tasks_by_project[proj] = [] + tasks_by_project[proj].append(task) + + # Select projects to dispatch (round-robin) + for project in sorted(tasks_by_project.keys()): + if available_slots <= 0: + break + + # Skip if project already running + if project in self.running_projects: + skipped.append({ + "project": project, + "reason": "already running" + }) + continue + + # Check if cockpit is running + if not container_running(project): + # Start cockpit first + start_result = cockpit_start(project, self.config) + if not start_result["success"]: + skipped.append({ + "project": project, + "reason": f"failed to start cockpit: {start_result['message']}" + }) + continue + + # Get first pending task for this project + task_data = tasks_by_project[project][0] + + # Claim slot via scheduler + if not self.scheduler.claim_task(task_data["id"], project): + skipped.append({ + "project": project, + "reason": "failed to claim scheduler slot" + }) + continue + + # Dispatch async + self.dispatch_task_async(project, task_data) + dispatched.append({ + "project": project, + "task_id": task_data["id"], + "task": task_data["task"][:50] + "..." + }) + + available_slots -= 1 + + return { + "cycle_time": cycle_start.isoformat(), + "dispatched": dispatched, + "skipped": skipped, + "running_count": len(self.running_projects), + "pending_count": len(pending), + "load_stats": self.load_monitor.get_stats() + } + + def get_status(self) -> Dict[str, Any]: + """Get dispatcher status.""" + return { + "running_projects": list(self.running_projects.keys()), + "running_count": len(self.running_projects), + "max_concurrent": self.max_concurrent, + "pending_tasks": len(self.get_pending_tasks()), + "load_stats": self.load_monitor.get_stats(), + "scheduler_status": self.scheduler.get_scheduling_status() + } + + +def run_dispatcher_daemon(config: dict, interval: int = 10) -> None: + """ + Run dispatcher as a daemon. + + Args: + config: Luzia config dict + interval: Seconds between dispatch cycles + """ + dispatcher = CockpitQueueDispatcher(config) + + print(f"[CockpitQueueDispatcher] Started (interval: {interval}s)") + + while True: + try: + result = dispatcher.run_dispatch_cycle() + + if result.get("dispatched"): + for d in result["dispatched"]: + print(f"[DISPATCHED] {d['project']}: {d['task']}") + + if result.get("reason"): + print(f"[CYCLE] {result['reason']}") + + except Exception as e: + print(f"[ERROR] Dispatch cycle failed: {e}") + + time.sleep(interval) + + +def main(): + """Test dispatcher.""" + import yaml + + config_path = Path("/opt/server-agents/orchestrator/config/luzia.yaml") + if config_path.exists(): + config = yaml.safe_load(config_path.read_text()) + else: + config = {"projects": {}} + + dispatcher = CockpitQueueDispatcher(config) + + print("=" * 60) + print("COCKPIT QUEUE DISPATCHER STATUS") + print("=" * 60) + print(json.dumps(dispatcher.get_status(), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/__pycache__/test_integrations.cpython-310-pytest-9.0.2.pyc b/tests/__pycache__/test_integrations.cpython-310-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0bf64185dafae9f451fceb71691bb467844a35d GIT binary patch literal 37794 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 A-2eap literal 0 HcmV?d00001