mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
Stabilize OpenSpace MCP daemon lifecycle
This commit is contained in:
parent
15bd9bbc25
commit
130a780e7f
14 changed files with 1598 additions and 56 deletions
627
openspace/codex_session_scenarios.py
Normal file
627
openspace/codex_session_scenarios.py
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
OPENING_PREFIX = "先做一次 OpenSpace 预检,再开始当前任务。"
|
||||
SESSION_STATUS_RE = re.compile(r"OpenSpace session:\s*(\S+)")
|
||||
MACHINE_STATUS_RE = re.compile(r"OpenSpace machine:\s*(\S+)")
|
||||
FALLBACK_LINE = "当前线程不依赖 OpenSpace 自动沉淀;我会先按本地文档、脚本或手动收尾路径继续。"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpeningStatus:
|
||||
session_status: str
|
||||
machine_status: str
|
||||
fallback_present: bool
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecOutput:
|
||||
thread_id: str | None
|
||||
events: list[dict[str, Any]]
|
||||
agent_messages: list[str]
|
||||
opening: OpeningStatus | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionFamily:
|
||||
thread_ids: set[str]
|
||||
session_files: set[Path]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunResult:
|
||||
label: str
|
||||
exit_code: int
|
||||
thread_id: str | None
|
||||
opening: dict[str, Any] | None
|
||||
agent_messages: list[str]
|
||||
mcp_tool_calls: list[dict[str, Any]]
|
||||
session_files: list[str]
|
||||
thread_ids: list[str]
|
||||
stdout_path: str
|
||||
stderr_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioResult:
|
||||
name: str
|
||||
ok: bool
|
||||
details: dict[str, Any]
|
||||
|
||||
|
||||
def _iter_json_events(output: str) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
events.append(payload)
|
||||
return events
|
||||
|
||||
|
||||
def _extract_agent_message(event: dict[str, Any]) -> str | None:
|
||||
if event.get("type") != "item.completed":
|
||||
return None
|
||||
item = event.get("item")
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
if item.get("type") != "agent_message":
|
||||
return None
|
||||
text = item.get("text")
|
||||
return text if isinstance(text, str) else None
|
||||
|
||||
|
||||
def _extract_mcp_tool_call(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if event.get("type") != "item.completed":
|
||||
return None
|
||||
item = event.get("item")
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
if item.get("type") != "mcp_tool_call":
|
||||
return None
|
||||
return {
|
||||
"server": item.get("server"),
|
||||
"tool": item.get("tool"),
|
||||
"status": item.get("status"),
|
||||
"error": item.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def _parse_opening(text: str) -> OpeningStatus | None:
|
||||
if not text.startswith(OPENING_PREFIX):
|
||||
return None
|
||||
session_match = SESSION_STATUS_RE.search(text)
|
||||
machine_match = MACHINE_STATUS_RE.search(text)
|
||||
if session_match is None or machine_match is None:
|
||||
return None
|
||||
return OpeningStatus(
|
||||
session_status=session_match.group(1),
|
||||
machine_status=machine_match.group(1),
|
||||
fallback_present=FALLBACK_LINE in text,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
def parse_exec_output(output: str) -> ExecOutput:
|
||||
events = _iter_json_events(output)
|
||||
thread_id: str | None = None
|
||||
agent_messages: list[str] = []
|
||||
opening: OpeningStatus | None = None
|
||||
|
||||
for event in events:
|
||||
if event.get("type") == "thread.started" and thread_id is None:
|
||||
candidate = event.get("thread_id")
|
||||
if isinstance(candidate, str):
|
||||
thread_id = candidate
|
||||
agent_message = _extract_agent_message(event)
|
||||
if agent_message is not None:
|
||||
agent_messages.append(agent_message)
|
||||
if opening is None:
|
||||
opening = _parse_opening(agent_message)
|
||||
|
||||
return ExecOutput(
|
||||
thread_id=thread_id,
|
||||
events=events,
|
||||
agent_messages=agent_messages,
|
||||
opening=opening,
|
||||
)
|
||||
|
||||
|
||||
def _read_session_meta(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
first_line = path.read_text(encoding="utf-8").splitlines()[0]
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(first_line)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if payload.get("type") != "session_meta":
|
||||
return None
|
||||
session_payload = payload.get("payload")
|
||||
return session_payload if isinstance(session_payload, dict) else None
|
||||
|
||||
|
||||
def collect_session_family(thread_id: str, sessions_root: Path) -> SessionFamily:
|
||||
metas: list[tuple[Path, dict[str, Any]]] = []
|
||||
for path in sessions_root.glob("**/*.jsonl"):
|
||||
meta = _read_session_meta(path)
|
||||
if meta is not None:
|
||||
metas.append((path, meta))
|
||||
|
||||
thread_ids: set[str] = {thread_id}
|
||||
session_files: set[Path] = set()
|
||||
changed = True
|
||||
|
||||
while changed:
|
||||
changed = False
|
||||
for path, meta in metas:
|
||||
current_id = meta.get("id")
|
||||
if isinstance(current_id, str) and current_id in thread_ids:
|
||||
if path not in session_files:
|
||||
session_files.add(path)
|
||||
changed = True
|
||||
|
||||
source = meta.get("source")
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
subagent = source.get("subagent")
|
||||
if not isinstance(subagent, dict):
|
||||
continue
|
||||
thread_spawn = subagent.get("thread_spawn")
|
||||
if not isinstance(thread_spawn, dict):
|
||||
continue
|
||||
parent_thread_id = thread_spawn.get("parent_thread_id")
|
||||
if (
|
||||
isinstance(parent_thread_id, str)
|
||||
and parent_thread_id in thread_ids
|
||||
and isinstance(current_id, str)
|
||||
and current_id not in thread_ids
|
||||
):
|
||||
thread_ids.add(current_id)
|
||||
session_files.add(path)
|
||||
changed = True
|
||||
|
||||
return SessionFamily(thread_ids=thread_ids, session_files=session_files)
|
||||
|
||||
|
||||
def cleanup_session_artifacts(
|
||||
*,
|
||||
thread_ids: set[str],
|
||||
session_files: set[Path],
|
||||
session_index_path: Path,
|
||||
) -> None:
|
||||
for path in session_files:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
if not session_index_path.exists():
|
||||
return
|
||||
|
||||
kept_lines: list[str] = []
|
||||
for line in session_index_path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
kept_lines.append(line)
|
||||
continue
|
||||
|
||||
payload_thread_id = payload.get("thread_id")
|
||||
payload_session_file = payload.get("session_file")
|
||||
if payload_thread_id in thread_ids:
|
||||
continue
|
||||
if isinstance(payload_session_file, str) and Path(payload_session_file) in session_files:
|
||||
continue
|
||||
kept_lines.append(line)
|
||||
|
||||
new_content = "\n".join(kept_lines)
|
||||
if new_content:
|
||||
new_content += "\n"
|
||||
session_index_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
|
||||
def snapshot_daemons(state_dir: Path, workspace: str) -> dict[str, dict[str, Any]]:
|
||||
snapshot: dict[str, dict[str, Any]] = {}
|
||||
if not state_dir.exists():
|
||||
return snapshot
|
||||
|
||||
for path in state_dir.glob("*.json"):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if payload.get("workspace") != workspace:
|
||||
continue
|
||||
server_kind = payload.get("server_kind")
|
||||
if not isinstance(server_kind, str):
|
||||
continue
|
||||
snapshot[server_kind] = payload
|
||||
return snapshot
|
||||
|
||||
|
||||
def kill_daemons_in_state_dir(state_dir: Path) -> None:
|
||||
if not state_dir.exists():
|
||||
return
|
||||
for path in state_dir.glob("*.json"):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
pid = payload.get("pid")
|
||||
if not isinstance(pid, int):
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
pass
|
||||
else:
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, AttributeError):
|
||||
pass
|
||||
shutil.rmtree(state_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def run_exec_session(
|
||||
*,
|
||||
label: str,
|
||||
prompt: str,
|
||||
cwd: Path,
|
||||
extra_configs: list[str] | None = None,
|
||||
timeout_seconds: int = 180,
|
||||
codex_binary: str = "codex",
|
||||
sessions_root: Path | None = None,
|
||||
session_index_path: Path | None = None,
|
||||
output_dir: Path | None = None,
|
||||
) -> RunResult:
|
||||
sessions_root = sessions_root or (Path.home() / ".codex" / "sessions")
|
||||
session_index_path = session_index_path or (Path.home() / ".codex" / "session_index.jsonl")
|
||||
output_dir = output_dir or Path(tempfile.mkdtemp(prefix="openspace-codex-session-"))
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path = output_dir / f"{label}.stdout.log"
|
||||
stderr_path = output_dir / f"{label}.stderr.log"
|
||||
|
||||
command = [
|
||||
codex_binary,
|
||||
"exec",
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"-C",
|
||||
str(cwd),
|
||||
]
|
||||
for item in extra_configs or []:
|
||||
command.extend(["-c", item])
|
||||
command.append(prompt)
|
||||
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
stdout_path.write_text(completed.stdout, encoding="utf-8")
|
||||
stderr_path.write_text(completed.stderr, encoding="utf-8")
|
||||
|
||||
parsed = parse_exec_output(completed.stdout)
|
||||
session_family = (
|
||||
collect_session_family(parsed.thread_id, sessions_root)
|
||||
if parsed.thread_id
|
||||
else SessionFamily(thread_ids=set(), session_files=set())
|
||||
)
|
||||
mcp_tool_calls = [
|
||||
call
|
||||
for event in parsed.events
|
||||
if (call := _extract_mcp_tool_call(event)) is not None
|
||||
]
|
||||
|
||||
return RunResult(
|
||||
label=label,
|
||||
exit_code=completed.returncode,
|
||||
thread_id=parsed.thread_id,
|
||||
opening=asdict(parsed.opening) if parsed.opening else None,
|
||||
agent_messages=parsed.agent_messages,
|
||||
mcp_tool_calls=mcp_tool_calls,
|
||||
session_files=sorted(str(path) for path in session_family.session_files),
|
||||
thread_ids=sorted(session_family.thread_ids),
|
||||
stdout_path=str(stdout_path),
|
||||
stderr_path=str(stderr_path),
|
||||
)
|
||||
|
||||
|
||||
def _session_env_overrides(state_dir: Path, workspace: Path) -> list[str]:
|
||||
return [
|
||||
f'mcp_servers.openspace.env.OPENSPACE_MCP_DAEMON_STATE_DIR="{state_dir}"',
|
||||
f'mcp_servers.openspace_evolution.env.OPENSPACE_MCP_DAEMON_STATE_DIR="{state_dir}"',
|
||||
'mcp_servers.openspace.env.OPENSPACE_MCP_PROXY_MODE="daemon"',
|
||||
'mcp_servers.openspace_evolution.env.OPENSPACE_MCP_PROXY_MODE="daemon"',
|
||||
f'mcp_servers.openspace.env.OPENSPACE_WORKSPACE="{workspace}"',
|
||||
f'mcp_servers.openspace_evolution.env.OPENSPACE_WORKSPACE="{workspace}"',
|
||||
]
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str, errors: list[str]) -> None:
|
||||
if not condition:
|
||||
errors.append(message)
|
||||
|
||||
|
||||
def _scenario_cold_start(base_dir: Path, cwd: Path) -> ScenarioResult:
|
||||
state_dir = base_dir / "cold-start-state"
|
||||
run = run_exec_session(
|
||||
label="cold-start",
|
||||
prompt=(
|
||||
"这是 OpenSpace 冷启动预检测试。严格按 TMP 的 AGENTS.md 做开场预检,"
|
||||
"然后只输出一句“cold-start done”。不要修改任何文件,也不要使用子代理。"
|
||||
),
|
||||
cwd=cwd,
|
||||
extra_configs=_session_env_overrides(state_dir, cwd),
|
||||
output_dir=base_dir / "cold-start-output",
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
opening = run.opening or {}
|
||||
_assert(run.exit_code == 0, f"cold-start exit_code={run.exit_code}", errors)
|
||||
_assert(opening.get("session_status") == "ready", f"cold-start session_status={opening.get('session_status')}", errors)
|
||||
_assert(opening.get("machine_status") == "ready", f"cold-start machine_status={opening.get('machine_status')}", errors)
|
||||
_assert(opening.get("fallback_present") is False, "cold-start unexpectedly printed fallback line", errors)
|
||||
_assert(run.agent_messages[-1:] == ["cold-start done"], f"cold-start final message={run.agent_messages[-1:]}", errors)
|
||||
|
||||
return ScenarioResult(
|
||||
name="cold-start-preflight",
|
||||
ok=not errors,
|
||||
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
|
||||
)
|
||||
|
||||
|
||||
def _scenario_warm_reuse(base_dir: Path, cwd: Path) -> ScenarioResult:
|
||||
state_dir = base_dir / "warm-reuse-state"
|
||||
extra_configs = _session_env_overrides(state_dir, cwd)
|
||||
prompt = (
|
||||
"这是 OpenSpace warm-session reuse 测试。先完成 TMP 的开场预检,"
|
||||
"然后调用 openspace 的 search_skills 工具,参数用 query='OpenSpace MCP 健康检查'、"
|
||||
"source='local'、limit=1、auto_import=false。最后只输出一句“warm-session done”。"
|
||||
"不要修改任何文件,也不要使用子代理。"
|
||||
)
|
||||
|
||||
first = run_exec_session(
|
||||
label="warm-reuse-first",
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
extra_configs=extra_configs,
|
||||
output_dir=base_dir / "warm-reuse-output",
|
||||
)
|
||||
first_snapshot = snapshot_daemons(state_dir, str(cwd))
|
||||
|
||||
second = run_exec_session(
|
||||
label="warm-reuse-second",
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
extra_configs=extra_configs,
|
||||
output_dir=base_dir / "warm-reuse-output",
|
||||
)
|
||||
second_snapshot = snapshot_daemons(state_dir, str(cwd))
|
||||
|
||||
errors: list[str] = []
|
||||
for label, run in (("first", first), ("second", second)):
|
||||
opening = run.opening or {}
|
||||
_assert(run.exit_code == 0, f"warm-reuse {label} exit_code={run.exit_code}", errors)
|
||||
_assert(opening.get("session_status") == "ready", f"warm-reuse {label} session_status={opening.get('session_status')}", errors)
|
||||
_assert(any(call.get("server") == "openspace" and call.get("tool") == "search_skills" for call in run.mcp_tool_calls), f"warm-reuse {label} missing openspace.search_skills call", errors)
|
||||
_assert(run.agent_messages[-1:] == ["warm-session done"], f"warm-reuse {label} final message={run.agent_messages[-1:]}", errors)
|
||||
|
||||
_assert("main" in first_snapshot, "warm-reuse first run did not create main daemon metadata", errors)
|
||||
_assert("main" in second_snapshot, "warm-reuse second run did not create main daemon metadata", errors)
|
||||
if "main" in first_snapshot and "main" in second_snapshot:
|
||||
_assert(
|
||||
first_snapshot["main"].get("pid") == second_snapshot["main"].get("pid"),
|
||||
f"warm-reuse main pid changed: {first_snapshot['main'].get('pid')} -> {second_snapshot['main'].get('pid')}",
|
||||
errors,
|
||||
)
|
||||
_assert(
|
||||
first_snapshot["main"].get("port") == second_snapshot["main"].get("port"),
|
||||
f"warm-reuse main port changed: {first_snapshot['main'].get('port')} -> {second_snapshot['main'].get('port')}",
|
||||
errors,
|
||||
)
|
||||
|
||||
return ScenarioResult(
|
||||
name="warm-session-reuse",
|
||||
ok=not errors,
|
||||
details={
|
||||
"errors": errors,
|
||||
"first": asdict(first),
|
||||
"second": asdict(second),
|
||||
"first_snapshot": first_snapshot,
|
||||
"second_snapshot": second_snapshot,
|
||||
"state_dirs": [str(state_dir)],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _scenario_unhealthy_fallback(base_dir: Path, cwd: Path) -> ScenarioResult:
|
||||
state_dir = base_dir / "unhealthy-state"
|
||||
missing_command = base_dir / "missing-openspace-command"
|
||||
run = run_exec_session(
|
||||
label="unhealthy-fallback",
|
||||
prompt=(
|
||||
"这是 OpenSpace unhealthy-session fallback 测试。严格按 TMP 的 AGENTS.md 做开场预检,"
|
||||
"然后只输出一句“unhealthy-session done”。不要修改任何文件,也不要使用子代理。"
|
||||
),
|
||||
cwd=cwd,
|
||||
extra_configs=_session_env_overrides(state_dir, cwd)
|
||||
+ [
|
||||
f'mcp_servers.openspace.command="{missing_command}"',
|
||||
f'mcp_servers.openspace_evolution.command="{missing_command}"',
|
||||
],
|
||||
output_dir=base_dir / "unhealthy-output",
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
opening = run.opening or {}
|
||||
_assert(run.exit_code == 0, f"unhealthy fallback exit_code={run.exit_code}", errors)
|
||||
_assert(
|
||||
opening.get("session_status") in {"exposed-but-unhealthy", "unknown"},
|
||||
f"unhealthy fallback session_status={opening.get('session_status')}",
|
||||
errors,
|
||||
)
|
||||
_assert(opening.get("machine_status") == "ready", f"unhealthy fallback machine_status={opening.get('machine_status')}", errors)
|
||||
_assert(opening.get("fallback_present") is True, "unhealthy fallback missing fallback line", errors)
|
||||
_assert(run.agent_messages[-1:] == ["unhealthy-session done"], f"unhealthy fallback final message={run.agent_messages[-1:]}", errors)
|
||||
|
||||
return ScenarioResult(
|
||||
name="unhealthy-session-fallback",
|
||||
ok=not errors,
|
||||
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
|
||||
)
|
||||
|
||||
|
||||
def _scenario_agent_team(base_dir: Path, cwd: Path) -> ScenarioResult:
|
||||
state_dir = base_dir / "agent-team-state"
|
||||
run = run_exec_session(
|
||||
label="agent-team",
|
||||
prompt=(
|
||||
"这是 OpenSpace agent-team 测试。先完成 TMP 的开场预检,然后使用 agent team,"
|
||||
"至少启动两个只读子代理,分别检查 TMP 仓库里的 AGENTS.md 和 "
|
||||
"scripts/check_openspace_mcp_preflight.py 与 OpenSpace 预检相关的内容。"
|
||||
"父线程最后只输出一句“agent-team done”。不要修改任何文件。"
|
||||
),
|
||||
cwd=cwd,
|
||||
extra_configs=_session_env_overrides(state_dir, cwd),
|
||||
timeout_seconds=240,
|
||||
output_dir=base_dir / "agent-team-output",
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
opening = run.opening or {}
|
||||
_assert(run.exit_code == 0, f"agent-team exit_code={run.exit_code}", errors)
|
||||
_assert(opening.get("session_status") == "ready", f"agent-team session_status={opening.get('session_status')}", errors)
|
||||
_assert(len(run.thread_ids) >= 3, f"agent-team expected parent + >=2 child threads, got {run.thread_ids}", errors)
|
||||
_assert(run.agent_messages[-1:] == ["agent-team done"], f"agent-team final message={run.agent_messages[-1:]}", errors)
|
||||
|
||||
return ScenarioResult(
|
||||
name="agent-team-split",
|
||||
ok=not errors,
|
||||
details={"errors": errors, "run": asdict(run), "state_dirs": [str(state_dir)]},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_run_artifacts(result: ScenarioResult, session_index_path: Path) -> None:
|
||||
details = result.details
|
||||
candidate_runs: list[dict[str, Any]] = []
|
||||
if "run" in details and isinstance(details["run"], dict):
|
||||
candidate_runs.append(details["run"])
|
||||
for key in ("first", "second"):
|
||||
if key in details and isinstance(details[key], dict):
|
||||
candidate_runs.append(details[key])
|
||||
|
||||
session_files: set[Path] = set()
|
||||
thread_ids: set[str] = set()
|
||||
for run in candidate_runs:
|
||||
for item in run.get("session_files", []):
|
||||
session_files.add(Path(item))
|
||||
for item in run.get("thread_ids", []):
|
||||
thread_ids.add(item)
|
||||
|
||||
cleanup_session_artifacts(
|
||||
thread_ids=thread_ids,
|
||||
session_files=session_files,
|
||||
session_index_path=session_index_path,
|
||||
)
|
||||
|
||||
|
||||
def run_scenarios(*, cwd: Path, cleanup: bool = True) -> dict[str, Any]:
|
||||
base_dir = Path(tempfile.mkdtemp(prefix="openspace-codex-scenarios-"))
|
||||
session_index_path = Path.home() / ".codex" / "session_index.jsonl"
|
||||
started_at = time.time()
|
||||
|
||||
scenario_steps = [
|
||||
("cold-start-preflight", _scenario_cold_start),
|
||||
("warm-session-reuse", _scenario_warm_reuse),
|
||||
("unhealthy-session-fallback", _scenario_unhealthy_fallback),
|
||||
("agent-team-split", _scenario_agent_team),
|
||||
]
|
||||
results: list[ScenarioResult] = []
|
||||
|
||||
try:
|
||||
for scenario_name, scenario_fn in scenario_steps:
|
||||
try:
|
||||
results.append(scenario_fn(base_dir, cwd))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append(
|
||||
ScenarioResult(
|
||||
name=scenario_name,
|
||||
ok=False,
|
||||
details={"errors": [f"{type(exc).__name__}: {exc}"]},
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if cleanup:
|
||||
for result in results:
|
||||
_cleanup_run_artifacts(result, session_index_path)
|
||||
for state_dir in base_dir.glob("*-state"):
|
||||
kill_daemons_in_state_dir(state_dir)
|
||||
shutil.rmtree(base_dir, ignore_errors=True)
|
||||
|
||||
return {
|
||||
"cwd": str(cwd),
|
||||
"started_at": started_at,
|
||||
"cleanup": cleanup,
|
||||
"all_ok": all(item.ok for item in results),
|
||||
"results": [asdict(item) for item in results],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run real Codex OpenSpace session scenarios")
|
||||
parser.add_argument(
|
||||
"--cwd",
|
||||
type=Path,
|
||||
default=Path("/Users/admin/PycharmProjects/TMP"),
|
||||
help="Working directory for real session scenarios",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-artifacts",
|
||||
action="store_true",
|
||||
help="Keep generated session files, daemon state, and command logs",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
summary = run_scenarios(cwd=args.cwd.resolve(), cleanup=not args.keep_artifacts)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0 if summary["all_ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -13,6 +13,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
|
@ -21,6 +22,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
|
||||
from openspace.mcp_tool_registration import register_evolution_tools
|
||||
|
||||
class _MCPSafeStdout:
|
||||
|
|
@ -82,11 +84,7 @@ _LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
|||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_real_stdout = sys.stdout
|
||||
if os.name == "nt":
|
||||
_stderr_file = open(
|
||||
_LOG_DIR / "evolution_mcp_stderr.log", "a", encoding="utf-8", buffering=1
|
||||
)
|
||||
sys.stderr = _stderr_file
|
||||
maybe_redirect_stderr_to_file(_LOG_DIR, "evolution_mcp_stderr.log")
|
||||
|
||||
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
|
||||
|
||||
|
|
@ -117,6 +115,8 @@ _idle_watchdog_started = False
|
|||
_activity_lock = threading.Lock()
|
||||
_active_request_count = 0
|
||||
_last_activity_at = time.monotonic()
|
||||
_shutdown_started = False
|
||||
_shutdown_lock = threading.Lock()
|
||||
|
||||
|
||||
def _json_ok(data: Any) -> str:
|
||||
|
|
@ -132,6 +132,9 @@ def _mark_request_start() -> None:
|
|||
with _activity_lock:
|
||||
_active_request_count += 1
|
||||
_last_activity_at = time.monotonic()
|
||||
from openspace.shared_mcp_runtime import update_current_daemon_status
|
||||
|
||||
update_current_daemon_status("evolution", touch=True, active_delta=1)
|
||||
|
||||
|
||||
def _mark_request_end() -> None:
|
||||
|
|
@ -139,6 +142,51 @@ def _mark_request_end() -> None:
|
|||
with _activity_lock:
|
||||
_active_request_count = max(0, _active_request_count - 1)
|
||||
_last_activity_at = time.monotonic()
|
||||
from openspace.shared_mcp_runtime import update_current_daemon_status
|
||||
|
||||
update_current_daemon_status("evolution", touch=True, active_delta=-1)
|
||||
|
||||
|
||||
def _shutdown_worker(reason: str) -> None:
|
||||
logger.info("Shutting down OpenSpace evolution daemon: %s", reason)
|
||||
instance = _openspace_instance
|
||||
if instance is not None and instance.is_initialized():
|
||||
try:
|
||||
asyncio.run(asyncio.wait_for(instance.cleanup(), timeout=10.0))
|
||||
except Exception as exc:
|
||||
logger.warning("OpenSpace evolution cleanup during shutdown failed: %s", exc)
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _begin_shutdown(reason: str) -> None:
|
||||
global _shutdown_started
|
||||
with _shutdown_lock:
|
||||
if _shutdown_started:
|
||||
return
|
||||
_shutdown_started = True
|
||||
|
||||
threading.Thread(
|
||||
target=_shutdown_worker,
|
||||
args=(reason,),
|
||||
name="openspace-evolution-shutdown",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def _install_signal_handlers() -> None:
|
||||
def _handle(signum, _frame) -> None:
|
||||
try:
|
||||
signame = signal.Signals(signum).name
|
||||
except Exception:
|
||||
signame = str(signum)
|
||||
_begin_shutdown(f"signal {signame}")
|
||||
|
||||
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||
try:
|
||||
signal.signal(signum, _handle)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
|
||||
|
|
@ -154,8 +202,8 @@ def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
|
|||
"Evolution MCP idle watchdog exiting process after %.1fs idle with no active requests",
|
||||
idle_for,
|
||||
)
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
_begin_shutdown(f"idle timeout after {idle_for:.1f}s")
|
||||
return
|
||||
|
||||
|
||||
def _maybe_start_idle_watchdog() -> None:
|
||||
|
|
@ -700,6 +748,7 @@ def run_mcp_server() -> None:
|
|||
args = parser.parse_args()
|
||||
|
||||
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
|
||||
_install_signal_handlers()
|
||||
_maybe_start_idle_watchdog()
|
||||
|
||||
mcp.settings.port = args.port
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import TextContent
|
||||
|
||||
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
|
||||
from openspace.grounding.backends.mcp.client import MCPClient
|
||||
from openspace.mcp_tool_registration import (
|
||||
register_evolution_tools,
|
||||
|
|
@ -17,6 +20,10 @@ from openspace.mcp_tool_registration import (
|
|||
from openspace.shared_mcp_runtime import ServerKind, ensure_daemon
|
||||
|
||||
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||
maybe_redirect_stderr_to_file(_LOG_DIR, "mcp_proxy_stderr.log")
|
||||
|
||||
|
||||
def _proxy_mode_for(server_kind: ServerKind) -> str:
|
||||
raw = os.environ.get("OPENSPACE_MCP_PROXY_MODE", "").strip().lower()
|
||||
if raw in {"daemon", "direct"}:
|
||||
|
|
@ -45,45 +52,38 @@ def _extract_text_payload(result: Any) -> str:
|
|||
class _RemoteProxyBase:
|
||||
def __init__(self, server_kind: ServerKind):
|
||||
self._server_kind = server_kind
|
||||
self._client: MCPClient | None = None
|
||||
self._current_url: str | None = None
|
||||
|
||||
async def _get_client(self) -> MCPClient:
|
||||
async def _call_remote_tool_once(self, tool_name: str, args: dict[str, Any]) -> str:
|
||||
record = await ensure_daemon(self._server_kind)
|
||||
if self._client is not None and self._current_url == record.url:
|
||||
return self._client
|
||||
|
||||
await self._reset_client()
|
||||
|
||||
self._client = MCPClient(
|
||||
client = MCPClient(
|
||||
config={"mcpServers": {"daemon": {"url": record.url}}},
|
||||
timeout=10.0,
|
||||
sse_read_timeout=60 * 60.0,
|
||||
check_dependencies=False,
|
||||
)
|
||||
self._current_url = record.url
|
||||
return self._client
|
||||
try:
|
||||
session = await client.create_session("daemon", auto_initialize=True)
|
||||
if session is None:
|
||||
raise RuntimeError("Failed to create daemon MCP session")
|
||||
result = await session.connector.call_tool(tool_name, args)
|
||||
return _extract_text_payload(result)
|
||||
finally:
|
||||
await client.close_all_sessions()
|
||||
|
||||
async def _reset_client(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.close_all_sessions()
|
||||
self._client = None
|
||||
self._current_url = None
|
||||
def _call_remote_tool_blocking(self, tool_name: str, args: dict[str, Any]) -> str:
|
||||
return asyncio.run(self._call_remote_tool_once(tool_name, args))
|
||||
|
||||
async def _call_remote_tool(self, tool_name: str, args: dict[str, Any]) -> str:
|
||||
for attempt in range(2):
|
||||
try:
|
||||
client = await self._get_client()
|
||||
session = await client.create_session("daemon", auto_initialize=True)
|
||||
if session is None:
|
||||
raise RuntimeError("Failed to create daemon MCP session")
|
||||
result = await session.connector.call_tool(tool_name, args)
|
||||
return _extract_text_payload(result)
|
||||
return await asyncio.to_thread(
|
||||
self._call_remote_tool_blocking,
|
||||
tool_name,
|
||||
args,
|
||||
)
|
||||
except Exception as exc:
|
||||
if attempt == 0:
|
||||
await self._reset_client()
|
||||
continue
|
||||
return _json_error(exc, status="error")
|
||||
if attempt == 1:
|
||||
return _json_error(exc, status="error")
|
||||
return _json_error("Unreachable proxy retry path", status="error")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ import inspect
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openspace.mcp_stdio import maybe_redirect_stderr_to_file
|
||||
from openspace.mcp_tool_registration import register_main_tools
|
||||
from openspace.shared_mcp_runtime import update_current_daemon_status
|
||||
|
||||
|
|
@ -89,17 +91,7 @@ _LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
|||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_real_stdout = sys.stdout
|
||||
|
||||
# Windows pipe buffers are small. When using stdio MCP transport,
|
||||
# the parent process only reads stdout for MCP messages and does NOT
|
||||
# drain stderr. Heavy log/print output during execute_task fills the stderr
|
||||
# pipe buffer, blocking this process on write() → deadlock → timeout.
|
||||
# Redirect stderr to a log file on Windows to prevent this.
|
||||
if os.name == "nt":
|
||||
_stderr_file = open(
|
||||
_LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1
|
||||
)
|
||||
sys.stderr = _stderr_file
|
||||
maybe_redirect_stderr_to_file(_LOG_DIR, "mcp_stderr.log")
|
||||
|
||||
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
|
||||
|
||||
|
|
@ -132,6 +124,8 @@ _active_request_count = 0
|
|||
_last_activity_at = time.monotonic()
|
||||
_embedding_prewarm_started = False
|
||||
_embedding_prewarm_lock = threading.Lock()
|
||||
_shutdown_started = False
|
||||
_shutdown_lock = threading.Lock()
|
||||
|
||||
# Internal state: tracks bot skill directories already registered this session.
|
||||
_registered_skill_dirs: set = set()
|
||||
|
|
@ -632,6 +626,7 @@ def _mark_request_start() -> None:
|
|||
with _activity_lock:
|
||||
_active_request_count += 1
|
||||
_last_activity_at = time.monotonic()
|
||||
update_current_daemon_status("main", touch=True, active_delta=1)
|
||||
|
||||
|
||||
def _mark_request_end() -> None:
|
||||
|
|
@ -639,6 +634,49 @@ def _mark_request_end() -> None:
|
|||
with _activity_lock:
|
||||
_active_request_count = max(0, _active_request_count - 1)
|
||||
_last_activity_at = time.monotonic()
|
||||
update_current_daemon_status("main", touch=True, active_delta=-1)
|
||||
|
||||
|
||||
def _shutdown_worker(reason: str) -> None:
|
||||
logger.info("Shutting down OpenSpace MCP daemon: %s", reason)
|
||||
instance = _openspace_instance
|
||||
if instance is not None and instance.is_initialized():
|
||||
try:
|
||||
asyncio.run(asyncio.wait_for(instance.cleanup(), timeout=10.0))
|
||||
except Exception as exc:
|
||||
logger.warning("OpenSpace MCP cleanup during shutdown failed: %s", exc)
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _begin_shutdown(reason: str) -> None:
|
||||
global _shutdown_started
|
||||
with _shutdown_lock:
|
||||
if _shutdown_started:
|
||||
return
|
||||
_shutdown_started = True
|
||||
|
||||
threading.Thread(
|
||||
target=_shutdown_worker,
|
||||
args=(reason,),
|
||||
name="openspace-mcp-shutdown",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
def _install_signal_handlers() -> None:
|
||||
def _handle(signum, _frame) -> None:
|
||||
try:
|
||||
signame = signal.Signals(signum).name
|
||||
except Exception:
|
||||
signame = str(signum)
|
||||
_begin_shutdown(f"signal {signame}")
|
||||
|
||||
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||
try:
|
||||
signal.signal(signum, _handle)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
|
||||
|
|
@ -654,8 +692,8 @@ def _idle_watchdog_loop(idle_timeout_seconds: int) -> None:
|
|||
"MCP idle watchdog exiting process after %.1fs idle with no active requests",
|
||||
idle_for,
|
||||
)
|
||||
logging.shutdown()
|
||||
os._exit(0)
|
||||
_begin_shutdown(f"idle timeout after {idle_for:.1f}s")
|
||||
return
|
||||
|
||||
|
||||
def _maybe_start_idle_watchdog() -> None:
|
||||
|
|
@ -1142,6 +1180,7 @@ def run_mcp_server() -> None:
|
|||
args = parser.parse_args()
|
||||
|
||||
if args.transport == "stdio" or os.environ.get("OPENSPACE_MCP_DAEMON") == "1":
|
||||
_install_signal_handlers()
|
||||
_maybe_start_idle_watchdog()
|
||||
if args.transport == "streamable-http":
|
||||
_maybe_start_main_daemon_embedding_prewarm()
|
||||
|
|
|
|||
42
openspace/mcp_stdio.py
Normal file
42
openspace/mcp_stdio.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
_STDERR_CAPTURE_HANDLE: TextIO | None = None
|
||||
|
||||
|
||||
def maybe_redirect_stderr_to_file(log_dir: Path, filename: str) -> TextIO | None:
|
||||
"""Redirect stderr to a log file when running as a non-interactive MCP child.
|
||||
|
||||
Codex and similar MCP hosts typically do not surface or continuously drain
|
||||
child-process stderr. Leaving verbose transport logs attached to a pipe can
|
||||
back up the buffer and stall stdio tool calls. For interactive terminals we
|
||||
keep stderr unchanged so local debugging still behaves normally.
|
||||
"""
|
||||
global _STDERR_CAPTURE_HANDLE
|
||||
|
||||
if os.environ.get("OPENSPACE_MCP_CAPTURE_STDERR", "").strip().lower() in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
}:
|
||||
return None
|
||||
|
||||
if _STDERR_CAPTURE_HANDLE is not None:
|
||||
return _STDERR_CAPTURE_HANDLE
|
||||
|
||||
try:
|
||||
if sys.stderr is not None and sys.stderr.isatty():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
handle = (log_dir / filename).open("a", encoding="utf-8", buffering=1)
|
||||
sys.stderr = handle
|
||||
_STDERR_CAPTURE_HANDLE = handle
|
||||
return handle
|
||||
|
|
@ -82,6 +82,8 @@ class MCPDaemonRecord:
|
|||
ready_at: float | None = None
|
||||
warmed_at: float | None = None
|
||||
warmup_error: str | None = None
|
||||
last_used_at: float | None = None
|
||||
active_requests: int = 0
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
|
|
@ -260,6 +262,82 @@ def _write_record(path: Path, record: MCPDaemonRecord) -> None:
|
|||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def _record_last_used_at(record: MCPDaemonRecord) -> float:
|
||||
return record.last_used_at or record.warmed_at or record.ready_at or record.started_at
|
||||
|
||||
|
||||
def _max_daemons_per_kind() -> int:
|
||||
raw = os.environ.get("OPENSPACE_MCP_MAX_DAEMONS_PER_KIND", "").strip()
|
||||
if not raw:
|
||||
return 8
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning("Invalid OPENSPACE_MCP_MAX_DAEMONS_PER_KIND=%r", raw)
|
||||
return 8
|
||||
|
||||
|
||||
def _unlink_record_artifacts(record: MCPDaemonRecord, state_dir: str) -> None:
|
||||
metadata_path, lock_path = _metadata_paths(record.server_kind, record.instance_key, state_dir)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
metadata_path.unlink()
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
lock_path.unlink()
|
||||
|
||||
|
||||
def _collect_records(state_dir: str, server_kind: ServerKind) -> list[MCPDaemonRecord]:
|
||||
state_path = Path(state_dir)
|
||||
if not state_path.is_dir():
|
||||
return []
|
||||
|
||||
records: list[MCPDaemonRecord] = []
|
||||
for metadata_path in sorted(state_path.glob(f"{server_kind}-*.json")):
|
||||
record = _read_record(metadata_path)
|
||||
if record is not None:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def _reap_state_dir_records(
|
||||
state_dir: str,
|
||||
server_kind: ServerKind,
|
||||
*,
|
||||
keep_instance_key: str | None = None,
|
||||
) -> None:
|
||||
max_records = _max_daemons_per_kind()
|
||||
live_records: list[MCPDaemonRecord] = []
|
||||
|
||||
for record in _collect_records(state_dir, server_kind):
|
||||
if not _pid_exists(record.pid) or not _pid_matches_server(record):
|
||||
_unlink_record_artifacts(record, state_dir)
|
||||
continue
|
||||
live_records.append(record)
|
||||
|
||||
if max_records <= 0 or len(live_records) <= max_records:
|
||||
return
|
||||
|
||||
remaining = len(live_records)
|
||||
for record in sorted(live_records, key=_record_last_used_at):
|
||||
if remaining <= max_records:
|
||||
break
|
||||
if keep_instance_key and record.instance_key == keep_instance_key:
|
||||
continue
|
||||
if record.active_requests > 0:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Reaping %s daemon pid=%s instance_key=%s last_used_at=%.3f to keep fleet <= %s",
|
||||
record.server_kind,
|
||||
record.pid,
|
||||
record.instance_key,
|
||||
_record_last_used_at(record),
|
||||
max_records,
|
||||
)
|
||||
_terminate_record_process(record)
|
||||
_unlink_record_artifacts(record, state_dir)
|
||||
remaining -= 1
|
||||
|
||||
|
||||
def _metadata_paths(
|
||||
server_kind: ServerKind,
|
||||
instance_key: str,
|
||||
|
|
@ -278,6 +356,8 @@ def update_current_daemon_status(
|
|||
ready: bool | None = None,
|
||||
warmed: bool | None = None,
|
||||
warmup_error: str | None = None,
|
||||
touch: bool = False,
|
||||
active_delta: int = 0,
|
||||
) -> MCPDaemonRecord | None:
|
||||
instance_key = os.environ.get("OPENSPACE_MCP_INSTANCE_KEY", "").strip()
|
||||
state_dir = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
|
||||
|
|
@ -303,6 +383,12 @@ def update_current_daemon_status(
|
|||
if warmup_error is not None:
|
||||
updates["warmup_error"] = warmup_error
|
||||
|
||||
if active_delta:
|
||||
updates["active_requests"] = max(0, record.active_requests + active_delta)
|
||||
touch = True
|
||||
if touch:
|
||||
updates["last_used_at"] = now
|
||||
|
||||
if not updates:
|
||||
return record
|
||||
|
||||
|
|
@ -412,6 +498,7 @@ def _spawn_daemon(identity: MCPDaemonIdentity, port: int) -> MCPDaemonRecord:
|
|||
**popen_kwargs,
|
||||
)
|
||||
log_handle.close()
|
||||
started_at = time.time()
|
||||
return MCPDaemonRecord(
|
||||
server_kind=identity.server_kind,
|
||||
instance_key=identity.instance_key,
|
||||
|
|
@ -423,8 +510,9 @@ def _spawn_daemon(identity: MCPDaemonIdentity, port: int) -> MCPDaemonRecord:
|
|||
backend_scope=list(identity.backend_scope),
|
||||
host_skill_dirs=list(identity.host_skill_dirs),
|
||||
grounding_config_fingerprint=identity.grounding_config_fingerprint,
|
||||
started_at=time.time(),
|
||||
started_at=started_at,
|
||||
log_path=str(log_path),
|
||||
last_used_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -466,10 +554,15 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
|
|||
identity.metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _FileLock(identity.lock_path):
|
||||
_reap_state_dir_records(
|
||||
identity.state_dir,
|
||||
server_kind,
|
||||
keep_instance_key=identity.instance_key,
|
||||
)
|
||||
existing = _read_record(identity.metadata_path)
|
||||
if existing and _pid_exists(existing.pid) and await _probe_record(existing):
|
||||
now = time.time()
|
||||
if not existing.ready or (server_kind != "main" and not existing.warmed):
|
||||
now = time.time()
|
||||
refreshed = MCPDaemonRecord(
|
||||
**{
|
||||
**asdict(existing),
|
||||
|
|
@ -480,16 +573,23 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
|
|||
existing.warmed_at
|
||||
or (now if (existing.warmed or server_kind != "main") else None)
|
||||
),
|
||||
"last_used_at": now,
|
||||
}
|
||||
)
|
||||
_write_record(identity.metadata_path, refreshed)
|
||||
return refreshed or existing
|
||||
return existing
|
||||
refreshed = MCPDaemonRecord(
|
||||
**{
|
||||
**asdict(existing),
|
||||
"last_used_at": now,
|
||||
}
|
||||
)
|
||||
_write_record(identity.metadata_path, refreshed)
|
||||
return refreshed
|
||||
|
||||
if existing:
|
||||
_terminate_record_process(existing)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
identity.metadata_path.unlink()
|
||||
_unlink_record_artifacts(existing, identity.state_dir)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for _ in range(3):
|
||||
|
|
@ -504,6 +604,7 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
|
|||
"ready_at": now,
|
||||
"warmed": (server_kind != "main"),
|
||||
"warmed_at": (now if server_kind != "main" else None),
|
||||
"last_used_at": now,
|
||||
}
|
||||
)
|
||||
_write_record(identity.metadata_path, updated)
|
||||
|
|
@ -513,7 +614,6 @@ async def ensure_daemon(server_kind: ServerKind) -> MCPDaemonRecord:
|
|||
f"Daemon for key={identity.instance_key} did not become ready"
|
||||
)
|
||||
_terminate_record_process(record)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
identity.metadata_path.unlink()
|
||||
_unlink_record_artifacts(record, identity.state_dir)
|
||||
|
||||
raise last_error or RuntimeError("Failed to start daemon")
|
||||
|
|
|
|||
|
|
@ -212,7 +212,9 @@ class Logger:
|
|||
|
||||
# Console Handler
|
||||
if log_to_console:
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
# Logs should never share stdout with structured program output
|
||||
# such as MCP stdio frames.
|
||||
ch = logging.StreamHandler(sys.stderr)
|
||||
ch.setLevel(resolved_level)
|
||||
ch.setFormatter(console_formatter)
|
||||
target_logger.addHandler(ch)
|
||||
|
|
@ -229,7 +231,8 @@ class Logger:
|
|||
|
||||
# Record log file location
|
||||
if not cls._configured:
|
||||
print(f"Log file enabled: {actual_log_file}")
|
||||
sys.stderr.write(f"Log file enabled: {actual_log_file}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
cls._configured = True
|
||||
|
||||
|
|
@ -293,7 +296,7 @@ class Logger:
|
|||
|
||||
@staticmethod
|
||||
def _stdout_supports_color() -> bool:
|
||||
return sys.stdout.isatty() and not os.getenv("NO_COLOR")
|
||||
return sys.stderr.isatty() and not os.getenv("NO_COLOR")
|
||||
|
||||
@classmethod
|
||||
def _resolve_level(cls, level: Optional[int]) -> int:
|
||||
|
|
|
|||
324
scripts/cleanup_openspace_daemons.py
Executable file
324
scripts/cleanup_openspace_daemons.py
Executable file
|
|
@ -0,0 +1,324 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
PROCESS_MARKERS = (
|
||||
"openspace.mcp_server",
|
||||
"openspace.evolution_mcp_server",
|
||||
)
|
||||
STATE_FILE_PREFIXES = ("main-", "evolution-")
|
||||
STATE_FILE_SUFFIXES = (".json", ".lock", ".log")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManagedProcess:
|
||||
pid: int
|
||||
command: str
|
||||
source: str
|
||||
state_dir: str | None = None
|
||||
record_file: str | None = None
|
||||
|
||||
|
||||
def default_state_dirs() -> list[Path]:
|
||||
env_override = os.environ.get("OPENSPACE_MCP_DAEMON_STATE_DIR", "").strip()
|
||||
candidates = [
|
||||
Path(env_override).expanduser().resolve() if env_override else None,
|
||||
Path.home() / ".codex" / "state" / "openspace",
|
||||
Path.home() / ".codex-openspace" / "state" / "openspace",
|
||||
Path.home() / "Library" / "Application Support" / "openspace" / "mcp-daemons",
|
||||
]
|
||||
|
||||
result: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in candidates:
|
||||
if path is None:
|
||||
continue
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
|
||||
def is_target_command(command: str) -> bool:
|
||||
return any(marker in command for marker in PROCESS_MARKERS)
|
||||
|
||||
|
||||
def pid_exists(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def command_for_pid(pid: int) -> str:
|
||||
if pid <= 0:
|
||||
return ""
|
||||
proc = subprocess.run(
|
||||
["ps", "-o", "command=", "-p", str(pid)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def collect_metadata_processes(state_dir: Path) -> tuple[list[ManagedProcess], list[dict[str, object]], list[str]]:
|
||||
managed: list[ManagedProcess] = []
|
||||
metadata_rows: list[dict[str, object]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not state_dir.is_dir():
|
||||
return managed, metadata_rows, warnings
|
||||
|
||||
for metadata_path in sorted(state_dir.glob("*.json")):
|
||||
try:
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"failed to read {metadata_path}: {exc}")
|
||||
continue
|
||||
|
||||
pid = int(payload.get("pid") or 0)
|
||||
command = command_for_pid(pid) if pid_exists(pid) else ""
|
||||
live_target = bool(command and is_target_command(command))
|
||||
metadata_rows.append(
|
||||
{
|
||||
"state_dir": str(state_dir),
|
||||
"record_file": metadata_path.name,
|
||||
"pid": pid,
|
||||
"port": payload.get("port"),
|
||||
"workspace": payload.get("workspace"),
|
||||
"server_kind": payload.get("server_kind"),
|
||||
"live_target": live_target,
|
||||
"command": command,
|
||||
}
|
||||
)
|
||||
if live_target:
|
||||
managed.append(
|
||||
ManagedProcess(
|
||||
pid=pid,
|
||||
command=command,
|
||||
source="metadata",
|
||||
state_dir=str(state_dir),
|
||||
record_file=metadata_path.name,
|
||||
)
|
||||
)
|
||||
|
||||
return managed, metadata_rows, warnings
|
||||
|
||||
|
||||
def collect_orphan_processes(excluded_pids: Iterable[int]) -> list[ManagedProcess]:
|
||||
excluded = set(excluded_pids)
|
||||
proc = subprocess.run(
|
||||
["ps", "-axo", "pid=,command="],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
results: list[ManagedProcess] = []
|
||||
for line in proc.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
pid_text, command = stripped.split(None, 1)
|
||||
except ValueError:
|
||||
continue
|
||||
pid = int(pid_text)
|
||||
if pid in excluded:
|
||||
continue
|
||||
if not is_target_command(command):
|
||||
continue
|
||||
results.append(ManagedProcess(pid=pid, command=command, source="orphan-scan"))
|
||||
return results
|
||||
|
||||
|
||||
def terminate_process(pid: int, timeout_seconds: float) -> str:
|
||||
if not pid_exists(pid):
|
||||
return "already-exited"
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if not pid_exists(pid):
|
||||
return "terminated"
|
||||
time.sleep(0.1)
|
||||
|
||||
if pid_exists(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline:
|
||||
if not pid_exists(pid):
|
||||
return "killed"
|
||||
time.sleep(0.05)
|
||||
return "kill-sent"
|
||||
|
||||
|
||||
def removable_state_files(state_dir: Path, keep_logs: bool) -> list[Path]:
|
||||
if not state_dir.is_dir():
|
||||
return []
|
||||
|
||||
removable: list[Path] = []
|
||||
for path in sorted(state_dir.iterdir()):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if not path.name.startswith(STATE_FILE_PREFIXES):
|
||||
continue
|
||||
if not path.name.endswith(STATE_FILE_SUFFIXES):
|
||||
continue
|
||||
if keep_logs and path.suffix == ".log":
|
||||
continue
|
||||
removable.append(path)
|
||||
return removable
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Clean OpenSpace MCP daemon processes and state files")
|
||||
parser.add_argument(
|
||||
"--state-dir",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Additional state directory to clean. Can be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be terminated/removed without changing anything.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-logs",
|
||||
action="store_true",
|
||||
help="Keep *.log files in state dirs while removing json/lock artifacts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=float,
|
||||
default=3.0,
|
||||
help="How long to wait after SIGTERM before SIGKILL.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print the final report as JSON.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
state_dirs = default_state_dirs()
|
||||
for raw in args.state_dir:
|
||||
state_dirs.append(Path(raw).expanduser().resolve())
|
||||
|
||||
deduped_state_dirs: list[Path] = []
|
||||
seen_state_dirs: set[str] = set()
|
||||
for path in state_dirs:
|
||||
key = str(path)
|
||||
if key in seen_state_dirs:
|
||||
continue
|
||||
seen_state_dirs.add(key)
|
||||
deduped_state_dirs.append(path)
|
||||
|
||||
metadata_processes: list[ManagedProcess] = []
|
||||
metadata_rows: list[dict[str, object]] = []
|
||||
warnings: list[str] = []
|
||||
for state_dir in deduped_state_dirs:
|
||||
managed, rows, row_warnings = collect_metadata_processes(state_dir)
|
||||
metadata_processes.extend(managed)
|
||||
metadata_rows.extend(rows)
|
||||
warnings.extend(row_warnings)
|
||||
|
||||
orphan_processes = collect_orphan_processes(proc.pid for proc in metadata_processes)
|
||||
|
||||
unique_processes: dict[int, ManagedProcess] = {}
|
||||
for proc in [*metadata_processes, *orphan_processes]:
|
||||
unique_processes.setdefault(proc.pid, proc)
|
||||
|
||||
process_actions: list[dict[str, object]] = []
|
||||
for proc in sorted(unique_processes.values(), key=lambda item: item.pid):
|
||||
action = "would-terminate" if args.dry_run else terminate_process(proc.pid, args.timeout_seconds)
|
||||
process_actions.append(
|
||||
{
|
||||
**asdict(proc),
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
|
||||
file_actions: list[dict[str, object]] = []
|
||||
for state_dir in deduped_state_dirs:
|
||||
for path in removable_state_files(state_dir, keep_logs=args.keep_logs):
|
||||
action = "would-remove"
|
||||
if not args.dry_run:
|
||||
path.unlink(missing_ok=True)
|
||||
action = "removed"
|
||||
file_actions.append(
|
||||
{
|
||||
"state_dir": str(state_dir),
|
||||
"path": str(path),
|
||||
"action": action,
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"state_dirs": [str(path) for path in deduped_state_dirs],
|
||||
"metadata_records": metadata_rows,
|
||||
"process_actions": process_actions,
|
||||
"file_actions": file_actions,
|
||||
"warnings": warnings,
|
||||
"dry_run": args.dry_run,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
print("OpenSpace daemon cleanup")
|
||||
print(f"dry run: {'yes' if args.dry_run else 'no'}")
|
||||
print("state dirs:")
|
||||
for path in report["state_dirs"]:
|
||||
print(f"- {path}")
|
||||
|
||||
print("\nprocesses:")
|
||||
if process_actions:
|
||||
for item in process_actions:
|
||||
print(
|
||||
f"- pid={item['pid']} source={item['source']} action={item['action']} "
|
||||
f"record={item.get('record_file') or '-'}"
|
||||
)
|
||||
else:
|
||||
print("- none")
|
||||
|
||||
print("\nfiles:")
|
||||
if file_actions:
|
||||
for item in file_actions:
|
||||
print(f"- {item['action']}: {item['path']}")
|
||||
else:
|
||||
print("- none")
|
||||
|
||||
if warnings:
|
||||
print("\nwarnings:")
|
||||
for warning in warnings:
|
||||
print(f"- {warning}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
11
scripts/test_codex_openspace_sessions.py
Normal file
11
scripts/test_codex_openspace_sessions.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from openspace.codex_session_scenarios import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
147
tests/test_codex_session_scenarios.py
Normal file
147
tests/test_codex_session_scenarios.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from openspace.codex_session_scenarios import (
|
||||
cleanup_session_artifacts,
|
||||
collect_session_family,
|
||||
parse_exec_output,
|
||||
snapshot_daemons,
|
||||
)
|
||||
|
||||
|
||||
def _write_session(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"type": "session_meta", "payload": payload}, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_exec_output_extracts_thread_and_opening_status() -> None:
|
||||
output = """
|
||||
2026-04-13T00:00:00Z WARN unrelated noise
|
||||
{"type":"thread.started","thread_id":"thread-parent"}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"先做一次 OpenSpace 预检,再开始当前任务。\\n\\nOpenSpace session: ready\\nOpenSpace machine: ready"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"cold-start done"}}
|
||||
""".strip()
|
||||
|
||||
result = parse_exec_output(output)
|
||||
|
||||
assert result.thread_id == "thread-parent"
|
||||
assert result.opening is not None
|
||||
assert result.opening.session_status == "ready"
|
||||
assert result.opening.machine_status == "ready"
|
||||
assert result.opening.fallback_present is False
|
||||
assert result.agent_messages[-1] == "cold-start done"
|
||||
|
||||
|
||||
def test_collect_session_family_discovers_descendants(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
parent = sessions_root / "2026/04/13/parent.jsonl"
|
||||
child = sessions_root / "2026/04/13/child.jsonl"
|
||||
grandchild = sessions_root / "2026/04/13/grandchild.jsonl"
|
||||
unrelated = sessions_root / "2026/04/13/unrelated.jsonl"
|
||||
|
||||
_write_session(parent, {"id": "parent-thread"})
|
||||
_write_session(
|
||||
child,
|
||||
{
|
||||
"id": "child-thread",
|
||||
"source": {
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": "parent-thread",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
_write_session(
|
||||
grandchild,
|
||||
{
|
||||
"id": "grandchild-thread",
|
||||
"source": {
|
||||
"subagent": {
|
||||
"thread_spawn": {
|
||||
"parent_thread_id": "child-thread",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
_write_session(unrelated, {"id": "other-thread"})
|
||||
|
||||
family = collect_session_family("parent-thread", sessions_root)
|
||||
|
||||
assert family.thread_ids == {"parent-thread", "child-thread", "grandchild-thread"}
|
||||
assert family.session_files == {parent, child, grandchild}
|
||||
|
||||
|
||||
def test_cleanup_session_artifacts_removes_files_and_index_rows(tmp_path: Path) -> None:
|
||||
sessions_root = tmp_path / "sessions"
|
||||
session_file = sessions_root / "2026/04/13/parent.jsonl"
|
||||
_write_session(session_file, {"id": "parent-thread"})
|
||||
|
||||
session_index = tmp_path / "session_index.jsonl"
|
||||
session_index.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps({"thread_id": "parent-thread", "session_file": str(session_file)}),
|
||||
json.dumps({"thread_id": "keep-thread", "session_file": "/tmp/keep.jsonl"}),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cleanup_session_artifacts(
|
||||
thread_ids={"parent-thread"},
|
||||
session_files={session_file},
|
||||
session_index_path=session_index,
|
||||
)
|
||||
|
||||
assert not session_file.exists()
|
||||
remaining = session_index.read_text(encoding="utf-8")
|
||||
assert "parent-thread" not in remaining
|
||||
assert "keep-thread" in remaining
|
||||
|
||||
|
||||
def test_snapshot_daemons_filters_by_workspace(tmp_path: Path) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
matching = state_dir / "main-match.json"
|
||||
other = state_dir / "main-other.json"
|
||||
|
||||
matching.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"server_kind": "main",
|
||||
"instance_key": "match",
|
||||
"pid": 111,
|
||||
"port": 9001,
|
||||
"workspace": "/tmp/workspace-a",
|
||||
"ready": True,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
other.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"server_kind": "main",
|
||||
"instance_key": "other",
|
||||
"pid": 222,
|
||||
"port": 9002,
|
||||
"workspace": "/tmp/workspace-b",
|
||||
"ready": True,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
snapshot = snapshot_daemons(state_dir, "/tmp/workspace-a")
|
||||
|
||||
assert set(snapshot) == {"main"}
|
||||
assert snapshot["main"]["pid"] == 111
|
||||
50
tests/test_logging_stdout_safety.py
Normal file
50
tests/test_logging_stdout_safety.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from openspace.utils.logging import Logger
|
||||
|
||||
|
||||
def test_log_file_enable_announcement_avoids_stdout(monkeypatch, tmp_path: Path) -> None:
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
monkeypatch.setattr("sys.stdout", stdout)
|
||||
monkeypatch.setattr("sys.stderr", stderr)
|
||||
|
||||
Logger.reset_configuration()
|
||||
Logger.configure(
|
||||
level=logging.INFO,
|
||||
log_to_console=False,
|
||||
log_to_file=str(tmp_path / "unit.log"),
|
||||
attach_to_root=True,
|
||||
)
|
||||
|
||||
assert stdout.getvalue() == ""
|
||||
assert "Log file enabled:" in stderr.getvalue()
|
||||
|
||||
Logger.reset_configuration()
|
||||
|
||||
|
||||
def test_console_logging_avoids_stdout(monkeypatch, tmp_path: Path) -> None:
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
monkeypatch.setattr("sys.stdout", stdout)
|
||||
monkeypatch.setattr("sys.stderr", stderr)
|
||||
|
||||
Logger.reset_configuration()
|
||||
Logger.configure(
|
||||
level=logging.INFO,
|
||||
log_to_console=True,
|
||||
log_to_file=str(tmp_path / "unit.log"),
|
||||
attach_to_root=True,
|
||||
)
|
||||
Logger.get_logger("openspace.test").info("console log should stay off stdout")
|
||||
|
||||
assert stdout.getvalue() == ""
|
||||
assert "console log should stay off stdout" in stderr.getvalue()
|
||||
|
||||
Logger.reset_configuration()
|
||||
|
|
@ -34,6 +34,11 @@ def test_stdio_entrypoint_uses_stdio_transport(module_name, monkeypatch) -> None
|
|||
"_maybe_start_idle_watchdog",
|
||||
lambda: watchdog_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_install_signal_handlers",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
module.run_mcp_server()
|
||||
|
||||
|
|
@ -63,6 +68,11 @@ def test_sse_entrypoint_does_not_forward_sse_params(module_name, monkeypatch) ->
|
|||
"_maybe_start_idle_watchdog",
|
||||
lambda: watchdog_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_install_signal_handlers",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
module.run_mcp_server()
|
||||
|
||||
|
|
@ -95,6 +105,11 @@ def test_streamable_http_entrypoint_uses_watchdog_for_daemon(
|
|||
"_maybe_start_idle_watchdog",
|
||||
lambda: watchdog_calls.append(True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_install_signal_handlers",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setenv("OPENSPACE_MCP_DAEMON", "1")
|
||||
|
||||
module.run_mcp_server()
|
||||
|
|
|
|||
48
tests/test_mcp_stdio.py
Normal file
48
tests/test_mcp_stdio.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from openspace import mcp_stdio
|
||||
|
||||
|
||||
class _FakeStream(io.StringIO):
|
||||
def __init__(self, *, tty: bool) -> None:
|
||||
super().__init__()
|
||||
self._tty = tty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._tty
|
||||
|
||||
|
||||
def _reset_capture() -> None:
|
||||
handle = mcp_stdio._STDERR_CAPTURE_HANDLE
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
mcp_stdio._STDERR_CAPTURE_HANDLE = None
|
||||
|
||||
|
||||
def test_redirects_stderr_for_non_interactive_sessions(monkeypatch, tmp_path: Path) -> None:
|
||||
_reset_capture()
|
||||
monkeypatch.setattr("sys.stderr", _FakeStream(tty=False))
|
||||
|
||||
handle = mcp_stdio.maybe_redirect_stderr_to_file(tmp_path, "stderr.log")
|
||||
|
||||
assert handle is not None
|
||||
assert Path(handle.name) == tmp_path / "stderr.log"
|
||||
assert Path(handle.name).exists()
|
||||
assert handle is mcp_stdio._STDERR_CAPTURE_HANDLE
|
||||
|
||||
_reset_capture()
|
||||
|
||||
|
||||
def test_keeps_stderr_for_interactive_sessions(monkeypatch, tmp_path: Path) -> None:
|
||||
_reset_capture()
|
||||
original = _FakeStream(tty=True)
|
||||
monkeypatch.setattr("sys.stderr", original)
|
||||
|
||||
handle = mcp_stdio.maybe_redirect_stderr_to_file(tmp_path, "stderr.log")
|
||||
|
||||
assert handle is None
|
||||
assert mcp_stdio._STDERR_CAPTURE_HANDLE is None
|
||||
assert original is not None
|
||||
|
|
@ -146,3 +146,90 @@ def test_update_current_daemon_status_timestamps_after_lock_wait(monkeypatch, tm
|
|||
assert updated is not None
|
||||
assert updated.warmed is True
|
||||
assert updated.warmed_at == 107.5
|
||||
|
||||
|
||||
def test_update_current_daemon_status_tracks_last_used_and_active_requests(monkeypatch, tmp_path) -> None:
|
||||
identity = _build_identity(tmp_path)
|
||||
metadata_path = identity.metadata_path
|
||||
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shared_mcp_runtime._write_record(metadata_path, _build_record(identity))
|
||||
|
||||
monkeypatch.setenv("OPENSPACE_MCP_INSTANCE_KEY", identity.instance_key)
|
||||
monkeypatch.setenv("OPENSPACE_MCP_DAEMON_STATE_DIR", identity.state_dir)
|
||||
|
||||
monkeypatch.setattr(shared_mcp_runtime.time, "time", lambda: 111.0)
|
||||
started = shared_mcp_runtime.update_current_daemon_status(
|
||||
"main",
|
||||
touch=True,
|
||||
active_delta=1,
|
||||
)
|
||||
assert started is not None
|
||||
assert started.active_requests == 1
|
||||
assert started.last_used_at == 111.0
|
||||
|
||||
monkeypatch.setattr(shared_mcp_runtime.time, "time", lambda: 114.5)
|
||||
finished = shared_mcp_runtime.update_current_daemon_status(
|
||||
"main",
|
||||
touch=True,
|
||||
active_delta=-1,
|
||||
)
|
||||
assert finished is not None
|
||||
assert finished.active_requests == 0
|
||||
assert finished.last_used_at == 114.5
|
||||
|
||||
|
||||
def test_reap_state_dir_records_limits_live_daemons_per_kind(monkeypatch, tmp_path) -> None:
|
||||
records = []
|
||||
for index, last_used_at in enumerate((10.0, 20.0, 30.0), start=1):
|
||||
identity = shared_mcp_runtime.MCPDaemonIdentity(
|
||||
server_kind="main",
|
||||
workspace=f"/tmp/workspace-{index}",
|
||||
resolved_model="unit-model",
|
||||
llm_kwargs_fingerprint=f"llm-{index}",
|
||||
backend_scope=("shell",),
|
||||
host_skill_dirs=(f"/tmp/skills-{index}",),
|
||||
grounding_config_fingerprint=f"cfg-{index}",
|
||||
instance_key=f"key-{index}",
|
||||
state_dir=str(tmp_path),
|
||||
)
|
||||
record = shared_mcp_runtime.MCPDaemonRecord(
|
||||
server_kind="main",
|
||||
instance_key=identity.instance_key,
|
||||
pid=4000 + index,
|
||||
port=56000 + index,
|
||||
workspace=identity.workspace,
|
||||
resolved_model=identity.resolved_model,
|
||||
llm_kwargs_fingerprint=identity.llm_kwargs_fingerprint,
|
||||
backend_scope=list(identity.backend_scope),
|
||||
host_skill_dirs=list(identity.host_skill_dirs),
|
||||
grounding_config_fingerprint=identity.grounding_config_fingerprint,
|
||||
started_at=1.0,
|
||||
log_path=str(tmp_path / f"{identity.instance_key}.log"),
|
||||
ready=True,
|
||||
warmed=True,
|
||||
last_used_at=last_used_at,
|
||||
active_requests=0,
|
||||
)
|
||||
shared_mcp_runtime._write_record(identity.metadata_path, record)
|
||||
records.append(record)
|
||||
|
||||
reaped: list[str] = []
|
||||
monkeypatch.setattr(shared_mcp_runtime, "_max_daemons_per_kind", lambda: 2)
|
||||
monkeypatch.setattr(shared_mcp_runtime, "_pid_exists", lambda pid: True)
|
||||
monkeypatch.setattr(shared_mcp_runtime, "_pid_matches_server", lambda record: True)
|
||||
monkeypatch.setattr(
|
||||
shared_mcp_runtime,
|
||||
"_terminate_record_process",
|
||||
lambda record: reaped.append(record.instance_key),
|
||||
)
|
||||
|
||||
shared_mcp_runtime._reap_state_dir_records(
|
||||
str(tmp_path),
|
||||
"main",
|
||||
keep_instance_key="key-3",
|
||||
)
|
||||
|
||||
assert reaped == ["key-1"]
|
||||
assert not (tmp_path / "main-key-1.json").exists()
|
||||
assert (tmp_path / "main-key-2.json").exists()
|
||||
assert (tmp_path / "main-key-3.json").exists()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue