Add process controls and restart command
This commit is contained in:
137
app/ai/processes.py
Normal file
137
app/ai/processes.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProcessRecord:
|
||||
name: str
|
||||
pid: int
|
||||
command: list[str]
|
||||
cwd: str
|
||||
log_file: str
|
||||
started_at: str
|
||||
|
||||
|
||||
class BackgroundProcessManager:
|
||||
def __init__(self, workspace_dir: Path) -> None:
|
||||
self.workspace_dir = workspace_dir.resolve()
|
||||
self.log_dir = self.workspace_dir / ".process-logs"
|
||||
self.state_file = self.workspace_dir / ".background-processes.json"
|
||||
self.processes: dict[str, ProcessRecord] = {}
|
||||
self._children: dict[str, asyncio.subprocess.Process] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
raw = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
for name, item in raw.items():
|
||||
try:
|
||||
item = dict(item)
|
||||
item.pop("name", None)
|
||||
self.processes[name] = ProcessRecord(name=name, **item)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
def _save(self) -> None:
|
||||
self.workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_file.write_text(
|
||||
json.dumps({name: asdict(record) for name, record in self.processes.items()}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _alive(self, pid: int) -> bool:
|
||||
if pid in {os.getpid(), 0}:
|
||||
return True
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except (OSError, ProcessLookupError):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def start(self, name: str, command: Any, cwd: str | None = None) -> str:
|
||||
name = name.strip()
|
||||
if not name or not name.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("background_start: name must contain only letters, digits, '-' or '_'")
|
||||
if isinstance(command, str):
|
||||
command = shlex.split(command, posix=(os.name != "nt"))
|
||||
if not isinstance(command, list) or not command or not all(isinstance(x, str) for x in command):
|
||||
raise ValueError("background_start: command must be a non-empty argv list or shell-like string")
|
||||
if name in self.processes and self._alive(self.processes[name].pid):
|
||||
raise ValueError(f"background_start: process {name!r} is already running")
|
||||
|
||||
process_cwd = self.workspace_dir if not cwd else (self.workspace_dir / cwd).resolve()
|
||||
if process_cwd != self.workspace_dir and self.workspace_dir not in process_cwd.parents:
|
||||
raise ValueError("background_start: cwd must be inside workspace_dir")
|
||||
process_cwd.mkdir(parents=True, exist_ok=True)
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = self.log_dir / f"{name}.log"
|
||||
handle = log_file.open("ab")
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"cwd": str(process_cwd),
|
||||
"stdout": handle,
|
||||
"stderr": asyncio.subprocess.STDOUT,
|
||||
}
|
||||
if os.name != "nt":
|
||||
kwargs["start_new_session"] = True
|
||||
process = await asyncio.create_subprocess_exec(*command, **kwargs)
|
||||
except Exception:
|
||||
handle.close()
|
||||
raise
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
record = ProcessRecord(
|
||||
name=name,
|
||||
pid=process.pid,
|
||||
command=command,
|
||||
cwd=str(process_cwd),
|
||||
log_file=str(log_file),
|
||||
started_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self.processes[name] = record
|
||||
self._children[name] = process
|
||||
self._save()
|
||||
return f"Started {name} (pid={process.pid}); log={log_file}"
|
||||
|
||||
async def stop(self, name: str) -> str:
|
||||
record = self.processes.get(name)
|
||||
if record is None:
|
||||
raise ValueError(f"background_stop: unknown process {name!r}")
|
||||
process = self._children.get(name)
|
||||
if process is not None and process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
elif self._alive(record.pid):
|
||||
try:
|
||||
os.kill(record.pid, signal.SIGTERM)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"background_stop failed: {exc}") from exc
|
||||
self.processes.pop(name, None)
|
||||
self._children.pop(name, None)
|
||||
self._save()
|
||||
return f"Stopped {name} (pid={record.pid})"
|
||||
|
||||
def status(self) -> str:
|
||||
if not self.processes:
|
||||
return "No background processes"
|
||||
lines = []
|
||||
for name, record in sorted(self.processes.items()):
|
||||
state = "running" if self._alive(record.pid) else "exited"
|
||||
lines.append(f"{name}: {state}, pid={record.pid}, log={record.log_file}, command={record.command!r}")
|
||||
return "\n".join(lines)
|
||||
@@ -15,11 +15,15 @@ from openai import AsyncOpenAI
|
||||
from app.ai.parser import AIInvocation
|
||||
from app.ai.prompts import PromptBook
|
||||
from app.ai.page_extract import PageExtractor
|
||||
from app.ai.processes import BackgroundProcessManager
|
||||
from app.ai.tools import (
|
||||
FILE_TOOL_DISPATCH,
|
||||
FILE_TOOL_SCHEMAS,
|
||||
PAGE_EXTRACT_SCHEMA,
|
||||
PYTHON_EXEC_SCHEMA,
|
||||
BACKGROUND_LIST_SCHEMA,
|
||||
BACKGROUND_START_SCHEMA,
|
||||
BACKGROUND_STOP_SCHEMA,
|
||||
WEB_SEARCH_SCHEMA,
|
||||
)
|
||||
from app.ai.web_search import BraveWebSearchClient, format_results
|
||||
@@ -75,6 +79,7 @@ class AIService:
|
||||
self._clients: dict[str, AsyncOpenAI] = {}
|
||||
self.web_search = BraveWebSearchClient(config.web_search)
|
||||
self.page_extractor = PageExtractor(config.page_extract)
|
||||
self.processes = BackgroundProcessManager(config.workspace_dir)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
return None
|
||||
@@ -221,7 +226,12 @@ class AIService:
|
||||
return messages
|
||||
|
||||
async def _build_tool_list(self) -> list[dict]:
|
||||
tools = FILE_TOOL_SCHEMAS + [PYTHON_EXEC_SCHEMA]
|
||||
tools = FILE_TOOL_SCHEMAS + [
|
||||
PYTHON_EXEC_SCHEMA,
|
||||
BACKGROUND_START_SCHEMA,
|
||||
BACKGROUND_STOP_SCHEMA,
|
||||
BACKGROUND_LIST_SCHEMA,
|
||||
]
|
||||
if self.web_search.available:
|
||||
tools.append(WEB_SEARCH_SCHEMA)
|
||||
if self.page_extractor.available:
|
||||
@@ -353,8 +363,18 @@ class AIService:
|
||||
return f"Tool {name!r}: invalid JSON arguments: {exc}"
|
||||
|
||||
try:
|
||||
if name == "python_exec":
|
||||
if name in {"python_exec", "background_start", "background_stop", "background_list"} and not ctx.is_owner:
|
||||
result = "Tool access denied: code execution and process control are owner-only"
|
||||
elif name == "python_exec":
|
||||
result = await self._execute_python_exec(arguments, ctx)
|
||||
elif name == "background_start":
|
||||
result = await self.processes.start(
|
||||
str(arguments.get("name", "")), arguments.get("command"), arguments.get("cwd")
|
||||
)
|
||||
elif name == "background_stop":
|
||||
result = await self.processes.stop(str(arguments.get("name", "")))
|
||||
elif name == "background_list":
|
||||
result = self.processes.status()
|
||||
elif name == "web_search":
|
||||
response = await self.web_search.search(arguments)
|
||||
result = format_results(response, include_heading=True)
|
||||
|
||||
@@ -219,6 +219,41 @@ PYTHON_EXEC_SCHEMA: dict[str, Any] = {
|
||||
},
|
||||
}
|
||||
|
||||
BACKGROUND_START_SCHEMA: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "background_start",
|
||||
"description": "Start an owner-approved long-running process without a shell. The process survives bot restarts; output is written to workspace/.process-logs/.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Stable process name"},
|
||||
"command": {"type": "array", "items": {"type": "string"}, "description": "Executable argv, e.g. [\"python\", \"server.py\"]"},
|
||||
"cwd": {"type": "string", "description": "Optional directory relative to workspace"},
|
||||
},
|
||||
"required": ["name", "command"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
BACKGROUND_STOP_SCHEMA: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "background_stop",
|
||||
"description": "Stop a background process previously started by background_start.",
|
||||
"parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
|
||||
},
|
||||
}
|
||||
|
||||
BACKGROUND_LIST_SCHEMA: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "background_list",
|
||||
"description": "List tracked background processes and their logs.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
WEB_SEARCH_SCHEMA: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
|
||||
Reference in New Issue
Block a user