"""Process Manager: observable tool execution via subprocess.""" import asyncio import subprocess import tempfile import time class Process: """A single observable tool execution.""" _next_id = 0 def __init__(self, tool: str, code: str, send_hud): Process._next_id += 1 self.pid = Process._next_id self.tool = tool self.code = code self.send_hud = send_hud self.status = "pending" self.output_lines: list[str] = [] self.exit_code: int | None = None self.started_at: float = 0 self.ended_at: float = 0 self._subprocess: subprocess.Popen | None = None async def hud(self, event: str, **data): await self.send_hud({"node": "process", "event": event, "pid": self.pid, "tool": self.tool, "status": self.status, **data}) def run_sync(self) -> str: """Execute the tool synchronously. Returns output.""" self.status = "running" self.started_at = time.time() try: with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f: f.write(self.code) f.flush() self._subprocess = subprocess.Popen( ['python3', f.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=tempfile.gettempdir() ) stdout, stderr = self._subprocess.communicate(timeout=10) self.exit_code = self._subprocess.returncode if stdout: self.output_lines.extend(stdout.strip().split("\n")) if self.exit_code != 0 and stderr: self.output_lines.append(f"[stderr: {stderr.strip()}]") self.status = "done" if self.exit_code == 0 else "failed" except subprocess.TimeoutExpired: if self._subprocess: self._subprocess.kill() self.output_lines.append("[error: timed out after 10s]") self.status = "failed" self.exit_code = -1 except Exception as e: self.output_lines.append(f"[error: {e}]") self.status = "failed" self.exit_code = -1 finally: self.ended_at = time.time() return "\n".join(self.output_lines) or "[no output]" def cancel(self): if self._subprocess and self.status == "running": self._subprocess.kill() self.status = "cancelled" self.ended_at = time.time() self.output_lines.append("[cancelled by user]") class ProcessManager: """Manages all tool executions as observable processes.""" def __init__(self, send_hud): self.send_hud = send_hud self.processes: dict[int, Process] = {} async def execute(self, tool: str, code: str) -> Process: """Create and run a process. Returns the completed Process.""" proc = Process(tool, code, self.send_hud) self.processes[proc.pid] = proc await proc.hud("process_start", code=code[:200]) loop = asyncio.get_event_loop() output = await loop.run_in_executor(None, proc.run_sync) elapsed = round(proc.ended_at - proc.started_at, 2) await proc.hud("process_done", exit_code=proc.exit_code, output=output[:500], elapsed=elapsed) return proc def cancel(self, pid: int) -> bool: proc = self.processes.get(pid) if proc: proc.cancel() return True return False def get_status(self) -> list[dict]: return [{"pid": p.pid, "tool": p.tool, "status": p.status, "elapsed": round((p.ended_at or time.time()) - p.started_at, 2) if p.started_at else 0} for p in self.processes.values()]