scout/manager.py
Rama main · réplica de lectura del repositorio original.
"""Scout Agent 后台守护进程管理命令 (cow 风格).
提供 start/stop/restart/status/logs/update/version 等管理命令,
通过 PID 文件 + nohup 日志实现后台守护进程管理。
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from typing import Optional
import click
from scout import __version__
_IS_WIN = sys.platform == "win32"
def get_project_root() -> str:
"""Scout 项目根目录 (scout/manager.py -> scout -> 项目根)."""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _get_pid_file() -> str:
return os.path.join(get_project_root(), ".scout.pid")
def _get_log_file() -> str:
return os.path.join(get_project_root(), "nohup.out")
def _get_web_port() -> int:
"""从配置读取 Web 服务端口,未配置则回退到默认 8848."""
try:
from scout.config.manager import ConfigManager
cfg = ConfigManager().load()
port = getattr(cfg, "web_port", None) or 8848
return int(port)
except Exception:
return 8848
def _web_entry() -> list:
"""后台启动 Web 服务的入口 (python -m scout.cli --web)."""
return [sys.executable, "-m", "scout.cli", "--web"]
def _is_pid_alive(pid: int) -> bool:
if _IS_WIN:
try:
out = subprocess.check_output(
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
stderr=subprocess.DEVNULL,
)
return str(pid) in out.decode(errors="ignore")
except Exception:
return False
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
def _kill_pid(pid: int, force: bool = False):
if _IS_WIN:
cmd = ["taskkill"]
if force:
cmd.append("/F")
cmd.extend(["/PID", str(pid)])
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
sig = signal.SIGKILL if force else signal.SIGTERM
os.kill(pid, sig)
def _read_pid() -> Optional[int]:
pid_file = _get_pid_file()
if not os.path.exists(pid_file):
return None
try:
with open(pid_file, "r", encoding="utf-8") as f:
pid = int(f.read().strip())
if _is_pid_alive(pid):
return pid
os.remove(pid_file)
return None
except (ValueError, OSError):
try:
os.remove(pid_file)
except OSError:
pass
return None
def _write_pid(pid: int):
with open(_get_pid_file(), "w", encoding="utf-8") as f:
f.write(str(pid))
def _remove_pid():
pid_file = _get_pid_file()
if os.path.exists(pid_file):
os.remove(pid_file)
def _find_leftover_scout_pids() -> list:
"""扫描所有 scout 相关进程(基于 /proc cmdline),排除自身.
兜底机制(2026-08-13):stop 原本只杀 .scout.pid 记录的 PID,
以下场景会漏:
- PID 文件丢失/过期,进程仍在跑
- 手动/其他方式启动、脱离 PID 文件管理的进程
- 后台 (scout restart &) 残留的 wrapper 进程(tail 日志不死)
"""
if _IS_WIN:
return []
pids = []
self_pid = os.getpid()
try:
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
pid = int(entry)
if pid == self_pid:
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmdline = f.read().replace(b"\x00", b" ").decode(errors="ignore")
except (OSError, PermissionError):
continue
if "scout" not in cmdline:
continue
# 防误杀:只匹配 python / scout 启动器进程,
# 排除恰好提到 scout 的 shell(如 bash -c "scout stop")和编辑器
tokens = cmdline.split()
if not tokens:
continue
first = tokens[0]
base = os.path.basename(first)
if "python" not in base and base != "scout":
continue
# 只匹配 scout 服务相关进程
if (
"scout.cli" in cmdline
or "scout start" in cmdline
or "scout restart" in cmdline
or "bin/scout" in cmdline
):
pids.append(pid)
except OSError:
pass
return pids
def _wait_port_free(port: int = 8848, timeout: float = 5.0) -> bool:
"""等待端口释放(防止 stop 后 start 撞上端口占用)."""
import socket
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.3)
try:
s.connect(("127.0.0.1", port))
except (ConnectionRefusedError, OSError, socket.timeout):
return True # 连不上 = 端口已释放
time.sleep(0.2)
return False
def _get_db_path() -> Optional[str]:
"""Scout 数据库文件路径(默认 <盘符>:\\.scout\\sessions.db)."""
try:
from scout.config.paths import DATA_DIR
return str(DATA_DIR / "sessions.db")
except Exception:
# 兜底: 项目所在盘符根目录(如 D:\.scout)
anchor = os.path.splitdrive(get_project_root())[0] # 如 "D:"
if anchor:
return os.path.join(anchor + os.sep, ".scout", "sessions.db")
return os.path.expanduser("~/.scout/sessions.db")
def _safe_backup_db() -> Optional[str]:
"""服务停止前对数据库做安全备份(WAL 安全 + 一致性快照).
要点(修复 2026-08-27,防止重启丢历史):
1. WAL 模式下直接 cp sessions.db 会丢失 -wal 中未 checkpoint 的数据,
必须先 PRAGMA wal_checkpoint(TRUNCATE) 把 WAL 合并进主库;
2. 备份必须用 sqlite3 的 .backup() API(在线一致性快照),不能文件复制;
3. 备份保存在 <db目录>/backups/,保留最近 20 份,防止磁盘膨胀。
"""
db_path = _get_db_path()
if not db_path or not os.path.exists(db_path):
return None
import sqlite3
bak_dir = os.path.join(os.path.dirname(db_path), "backups")
os.makedirs(bak_dir, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
bak_path = os.path.join(bak_dir, f"sessions.db.bak_{ts}")
try:
# 1. 合并 WAL → 主库(服务仍在运行,checkpoint 是安全的在线操作)
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
conn.close()
# 2. 一致性快照(sqlite backup API,而非 cp 文件复制)
src = sqlite3.connect(db_path)
try:
dst = sqlite3.connect(bak_path)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
# 3. 清理旧备份,只保留最近 20 份
snaps = sorted(
f for f in os.listdir(bak_dir)
if f.startswith("sessions.db.bak_")
)
for old in snaps[:-20]:
try:
os.remove(os.path.join(bak_dir, old))
except OSError:
pass
return bak_path
except Exception as e:
click.echo(click.style(f"⚠️ 数据库备份失败: {e}", fg="yellow"), err=True)
return None
def _print_last_lines(file_path: str, n: int = 50):
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
for line in all_lines[-n:]:
click.echo(line, nl=False)
except Exception as e:
click.echo(f"Error reading log file: {e}", err=True)
def _tail_log(log_file: str, lines: int = 50):
_print_last_lines(log_file, lines)
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
f.seek(0, 2)
while True:
line = f.readline()
if line:
click.echo(line, nl=False)
else:
time.sleep(0.3)
except KeyboardInterrupt:
pass
@click.command()
@click.option("--foreground", "-f", is_flag=True, help="前台运行 (不后台守护)")
@click.option("--no-logs", is_flag=True, help="启动后不跟踪日志")
def start(foreground, no_logs):
"""后台启动 Scout Web 服务 (默认端口 8848)."""
pid = _read_pid()
if pid:
click.echo(f"Scout is already running (PID: {pid}). Use 'scout restart' to restart.")
return
# 端口检查:防止 PID 文件缺失但服务仍在跑(脱离管理的进程)时新进程启动即崩
_port = _get_web_port()
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _s:
_s.settimeout(0.5)
if _s.connect_ex(("127.0.0.1", _port)) == 0:
click.echo(click.style(
f"⚠️ 端口 {_port} 已被占用(可能有脱离 PID 文件管理的 scout 进程在跑)。\n"
" 请先执行 'scout stop'(会兜底清理残留进程)再启动。",
fg="yellow",
))
return
root = get_project_root()
log_file = _get_log_file()
if foreground:
click.echo("Starting Scout in foreground...")
if _IS_WIN:
sys.exit(subprocess.call(_web_entry(), cwd=root))
else:
os.execv(sys.executable, _web_entry())
else:
click.echo("Starting Scout Web service...")
popen_kwargs = dict(cwd=root)
if _IS_WIN:
CREATE_NO_WINDOW = 0x08000000
popen_kwargs["creationflags"] = (
subprocess.CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
)
else:
popen_kwargs["start_new_session"] = True
with open(log_file, "a", encoding="utf-8") as log:
proc = subprocess.Popen(
_web_entry(),
stdout=log,
stderr=log,
**popen_kwargs,
)
_write_pid(proc.pid)
click.echo(click.style(f"✓ Scout started (PID: {proc.pid})", fg="green"))
click.echo(f" Web: http://localhost:{_port}")
click.echo(f" Logs: {log_file}")
if not no_logs:
click.echo(" Press Ctrl+C to stop tailing logs.\n")
_tail_log(log_file)
@click.command()
def stop():
"""停止 Scout 服务(含兜底清理:PID 文件之外的残留进程)."""
# ★ 修复 2026-08-27:停止/重启前自动做安全备份(WAL checkpoint + backup API),
# 防止重启覆盖/回滚导致对话历史丢失
bak = _safe_backup_db()
if bak:
click.echo(click.style(f"✓ 已自动备份数据库 → {bak}", fg="cyan"))
pid = _read_pid()
if pid:
click.echo(f"Stopping Scout (PID: {pid})...")
try:
_kill_pid(pid)
for _ in range(30):
time.sleep(0.1)
if not _is_pid_alive(pid):
break
else:
_kill_pid(pid, force=True)
except (ProcessLookupError, OSError):
pass
_remove_pid()
else:
click.echo("PID 文件无记录(可能未运行或已脱离管理)")
# ── 兜底清理:PID 文件之外的 scout 残留进程 ──
leftovers = _find_leftover_scout_pids()
if leftovers:
click.echo(f"发现 {len(leftovers)} 个残留进程: {leftovers}")
for p in leftovers:
try:
_kill_pid(p)
except (ProcessLookupError, OSError):
pass
time.sleep(0.5)
# 仍存活的强制杀
for p in _find_leftover_scout_pids():
try:
_kill_pid(p, force=True)
except (ProcessLookupError, OSError):
pass
# 等待端口释放,避免随后 start 撞端口
_port = _get_web_port()
if not _wait_port_free(_port, timeout=5):
click.echo(click.style(f"⚠️ 端口 {_port} 仍被占用,start 可能失败", fg="yellow"))
click.echo(click.style("✓ Scout stopped.", fg="green"))
@click.command()
@click.option("--no-logs", is_flag=True, help="重启后不跟踪日志")
@click.pass_context
def restart(ctx, no_logs):
"""重启 Scout 服务."""
ctx.invoke(stop)
time.sleep(1)
ctx.invoke(start, no_logs=no_logs)
@click.command()
def status():
"""查看 Scout 运行状态."""
pid = _read_pid()
if pid:
click.echo(click.style(f"● Scout is running (PID: {pid})", fg="green"))
else:
click.echo(click.style("● Scout is not running", fg="red"))
click.echo(f" Version: v{__version__}")
try:
from scout.config.manager import ConfigManager
cfg = ConfigManager().load()
click.echo(f" Provider: {cfg.provider}")
click.echo(f" Model: {cfg.model}")
web_port = getattr(cfg, "web_port", None) or 8848
click.echo(f" Web: http://localhost:{web_port}")
except Exception:
pass
@click.command()
@click.option("--follow", "-f", is_flag=True, help="跟踪日志输出")
@click.option("--lines", "-n", default=50, help="显示的日志行数")
def logs(follow, lines):
"""查看 Scout 日志."""
log_file = _get_log_file()
if not os.path.exists(log_file):
click.echo("No log file found. Start the service first with 'scout start'.")
return
if follow:
_tail_log(log_file, lines)
else:
_print_last_lines(log_file, lines)
@click.command()
@click.pass_context
def update(ctx):
"""更新 Scout 代码并重启."""
root = get_project_root()
ctx.invoke(stop)
if os.path.isdir(os.path.join(root, ".git")):
click.echo("Pulling latest code...")
ret = subprocess.call(["git", "pull"], cwd=root)
if ret != 0:
click.echo("Error: git pull failed.", err=True)
sys.exit(1)
else:
click.echo("Not a git repository, skipping code update.")
req_file = os.path.join(root, "requirements.txt")
if os.path.exists(req_file):
click.echo("Installing dependencies...")
subprocess.call(
[sys.executable, "-m", "pip", "install", "-r", req_file, "-q"],
cwd=root,
)
click.echo("Reinstalling scout CLI...")
subprocess.call(
[sys.executable, "-m", "pip", "install", "-e", ".", "-q"],
cwd=root,
)
click.echo("")
# 升级能力:代码更新后提示数据格式迁移状态
try:
from scout.config.manifest import check_data_format
compatible, msg = check_data_format()
if compatible:
click.echo(f"[数据格式] {msg}")
else:
click.echo(f"[警告] {msg}", err=True)
except Exception:
pass
time.sleep(1)
ctx.invoke(start, no_logs=False)
@click.command()
def version():
"""显示版本."""
click.echo(f"scout v{__version__}")
# 数据格式版本提示(升级能力):显示当前数据目录的格式版本
try:
from scout.config.manifest import (
DATA_FORMAT_VERSION,
get_data_format_version,
)
current = get_data_format_version()
status = "✓" if current >= DATA_FORMAT_VERSION else "↑ 升级后首次启动将自动迁移"
click.echo(f"数据格式版本 v{current}(当前程序支持 v{DATA_FORMAT_VERSION}){status}")
except Exception:
pass