Split 1161-line monolith into agent/ package: auth, llm, types, process, runtime, api, and nodes/ (base, sensor, input, output, thinker, memorizer). No logic changes — pure structural split. uvicorn agent:app entrypoint unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""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()]
|