zcbot/tools/check_process.py

119 lines
4.8 KiB
Python

"""check_process: 查看/终止 shell·run_python `background=true` 启动的后台进程。
host in-process 工具(不进 CONTAINER_TOOLS):状态文件在宿主侧 user_root/.zcbot_procs,
docker 模式的容器探测/回收走宿主 docker CLI —— 两种 backend 一个工具通吃。
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
from typing import Optional
from core import procs
from .base import Tool, compact_tool_output
class CheckProcessTool(Tool):
name = "check_process"
description = (
"Check status / output of background processes started with background=true "
"(shell or run_python), or kill one. "
"Call without proc_id to list this task's background processes. "
"With proc_id: returns status + tail of the process log. "
"If still running: do NOT poll in a tight loop — do other useful work, or end "
"the turn and tell the user to ask later ('跑完了吗'). "
"action='kill' force-terminates the process."
)
parameters = {
"type": "object",
"properties": {
"proc_id": {
"type": "string",
"description": "后台进程 id(启动时返回的 p 开头短串)。缺省 = 列出本 task 全部后台进程。",
},
"action": {
"type": "string",
"enum": ["status", "kill"],
"description": "status(默认)= 查状态+日志尾部;kill = 强制终止该进程。",
},
"tail_bytes": {
"type": "integer",
"description": "返回日志尾部的最大字节数,默认 4000,上限 20000。",
},
},
"required": [],
}
def __init__(
self,
*,
task_id: str,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir, user_root=user_root)
self.task_id = str(task_id)
@property
def _anchor(self) -> Path:
# 常规部署有 user_root;CLI --working-dir 指外部目录时退 base_dir
return self.user_root or self.base_dir
def execute(
self,
proc_id: Optional[str] = None,
action: str = "status",
tail_bytes: int = 4000,
) -> str:
if not proc_id:
items = procs.list_procs(self._anchor, self.task_id)
if not items:
return "本 task 没有后台进程。用 shell/run_python 的 background=true 可启动。"
return "后台进程列表:\n" + "\n".join(procs.format_proc_line(m) for m in items)
d = procs.proc_dir(self._anchor, self.task_id, proc_id.strip())
if d is None or not d.is_dir():
return f"[Error] 后台进程不存在: {proc_id!r}(用 check_process 不带参数列出现有进程)"
meta = procs.read_meta(d)
if meta is None:
return f"[Error] 后台进程元数据损坏: {proc_id!r}"
if action == "kill":
msg = procs.kill_proc(meta, d)
return f"[check_process] {proc_id}: {msg}"
st, ec = procs.status_of(meta, d)
tail_bytes = max(500, min(int(tail_bytes or 4000), 20_000))
tail = procs.tail_log(d, max_bytes=tail_bytes)
# docker 模式:已结束但专用容器还在空转 → 顺手回收(幂等,sweep 也会兜底)
if st != "running" and meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name:
try:
subprocess.run(["docker", "rm", "-f", name],
capture_output=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
pass
header = f"[check_process] {proc_id} · {meta.get('kind')} · {meta.get('command', '')[:150]}"
if st == "running":
elapsed = procs._fmt_elapsed(time.time() - float(meta.get("created_ts") or time.time()))
body = (
f"状态: running(已运行 {elapsed},上限 {meta.get('timeout_s')}s)\n"
f"--- 日志尾部 ---\n{tail}\n"
f"提示:仍在运行。别循环轮询 —— 先做别的工作,或结束回合告知用户稍后询问进度。"
)
elif st == "finished":
note = " (timeout killed)" if ec == 124 else (" (killed)" if meta.get("killed") else "")
body = f"状态: finished, exit_code={ec}{note}\n--- 日志尾部 ---\n{tail}"
else:
body = (
f"状态: lost(进程已不在,且未留下退出码 —— 可能宿主重启把它带走了)\n"
f"--- 日志尾部(截至中断) ---\n{tail}\n"
f"如需结果请重新以 background=true 启动。"
)
return compact_tool_output(f"{header}\n{body}")