Add process controls and restart command

This commit is contained in:
Azimkin
2026-08-28 23:25:20 +02:00
parent 12434ec557
commit d3c02ca203
14 changed files with 358 additions and 4 deletions

137
app/ai/processes.py Normal file
View 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)

View File

@@ -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)

View File

@@ -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": {