feat(installer): improve dev setup and gateway restart flow

This commit is contained in:
tjb-tech
2026-04-10 06:28:02 +00:00
parent 038d0a7956
commit 4e16ce0ffa
11 changed files with 521 additions and 40 deletions
+30 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
@@ -52,9 +53,16 @@ def _format_gateway_error(exc: Exception) -> str:
class OhmoGatewayBridge:
"""Consume inbound messages and publish assistant replies."""
def __init__(self, *, bus: MessageBus, runtime_pool: OhmoSessionRuntimePool) -> None:
def __init__(
self,
*,
bus: MessageBus,
runtime_pool: OhmoSessionRuntimePool,
restart_gateway: Callable[[object, str], Awaitable[None] | None] | None = None,
) -> None:
self._bus = bus
self._runtime_pool = runtime_pool
self._restart_gateway = restart_gateway
self._running = False
self._session_tasks: dict[str, asyncio.Task[None]] = {}
self._session_cancel_reasons: dict[str, str] = {}
@@ -81,6 +89,9 @@ class OhmoGatewayBridge:
if message.content.strip() == "/stop":
await self._handle_stop(message, session_key)
continue
if message.content.strip() == "/restart":
await self._handle_restart(message, session_key)
continue
await self._interrupt_session(
session_key,
reason="replaced by a newer user message",
@@ -119,6 +130,24 @@ class OhmoGatewayBridge:
)
)
async def _handle_restart(self, message, session_key: str) -> None:
await self._interrupt_session(
session_key,
reason="restarting gateway by user command",
)
await self._bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content="🔄 正在重启 gateway,马上回来。\nRestarting the gateway now. I'll be back in a moment.",
metadata={"_session_key": session_key},
)
)
if self._restart_gateway is not None:
result = self._restart_gateway(message, session_key)
if asyncio.iscoroutine(result):
await result
async def _interrupt_session(
self,
session_key: str,
+95 -2
View File
@@ -4,13 +4,16 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import os.path
import signal
import subprocess
import sys
from pathlib import Path
from openharness.channels.bus.events import OutboundMessage
from openharness.channels.bus.queue import MessageBus
from openharness.channels.impl.manager import ChannelManager
@@ -18,7 +21,13 @@ from ohmo.gateway.bridge import OhmoGatewayBridge
from ohmo.gateway.config import build_channel_manager_config, load_gateway_config
from ohmo.gateway.models import GatewayState
from ohmo.gateway.runtime import OhmoSessionRuntimePool
from ohmo.workspace import get_logs_dir, get_state_path, get_workspace_root, initialize_workspace
from ohmo.workspace import (
get_gateway_restart_notice_path,
get_logs_dir,
get_state_path,
get_workspace_root,
initialize_workspace,
)
logger = logging.getLogger(__name__)
_REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -40,7 +49,13 @@ class OhmoGatewayService:
workspace=self._workspace,
provider_profile=self._config.provider_profile,
)
self._bridge = OhmoGatewayBridge(bus=self._bus, runtime_pool=self._runtime_pool)
self._stop_event: asyncio.Event | None = None
self._restart_requested = False
self._bridge = OhmoGatewayBridge(
bus=self._bus,
runtime_pool=self._runtime_pool,
restart_gateway=self.request_restart,
)
self._manager = ChannelManager(build_channel_manager_config(self._config), self._bus)
@property
@@ -66,12 +81,83 @@ class OhmoGatewayService:
)
self.state_file.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
async def request_restart(self, message, session_key: str) -> None:
"""Ask the foreground gateway loop to restart itself."""
restart_notice = {
"channel": message.channel,
"chat_id": message.chat_id,
"session_key": session_key,
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
}
get_gateway_restart_notice_path(self._workspace).write_text(
json.dumps(restart_notice, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
self._restart_requested = True
# Let the outbound dispatcher flush the restart notice to the IM channel
# before we tear down the bridge and channel connections.
await asyncio.sleep(0.75)
if self._stop_event is not None:
self._stop_event.set()
def _exec_restart(self) -> None:
root = str(get_workspace_root(self._workspace))
argv = [
sys.executable,
"-m",
"ohmo",
"gateway",
"run",
"--cwd",
self._cwd,
"--workspace",
root,
]
logger.info("ohmo gateway restarting in-place argv=%s", argv)
os.execv(sys.executable, argv)
async def _publish_pending_restart_notice(self) -> None:
path = get_gateway_restart_notice_path(self._workspace)
if not path.exists():
return
try:
payload = json.loads(path.read_text(encoding="utf-8"))
channel = payload.get("channel")
chat_id = payload.get("chat_id")
content = payload.get("content")
session_key = payload.get("session_key")
if not isinstance(channel, str) or not isinstance(chat_id, str) or not isinstance(content, str):
return
await asyncio.sleep(2.0)
await self._bus.publish_outbound(
OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
metadata={"_session_key": session_key} if isinstance(session_key, str) else {},
)
)
logger.info(
"ohmo gateway published restart confirmation channel=%s chat_id=%s session_key=%s",
channel,
chat_id,
session_key,
)
finally:
path.unlink(missing_ok=True)
async def run_foreground(self) -> int:
self.pid_file.write_text(str(os.getpid()), encoding="utf-8")
self.write_state(running=True)
bridge_task = asyncio.create_task(self._bridge.run(), name="ohmo-gateway-bridge")
manager_task = asyncio.create_task(self._manager.start_all(), name="ohmo-gateway-channels")
restart_notice_task = asyncio.create_task(
self._publish_pending_restart_notice(),
name="ohmo-gateway-restart-notice",
)
stop_event = asyncio.Event()
self._stop_event = stop_event
self._restart_requested = False
def _stop(*_: object) -> None:
stop_event.set()
@@ -91,9 +177,16 @@ class OhmoGatewayService:
await bridge_task
with contextlib.suppress(asyncio.CancelledError):
await manager_task
if not restart_notice_task.done():
restart_notice_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await restart_notice_task
await self._manager.stop_all()
self.write_state(running=False)
self.pid_file.unlink(missing_ok=True)
self._stop_event = None
if self._restart_requested:
self._exec_restart()
return 0
+4
View File
@@ -227,6 +227,10 @@ def get_gateway_config_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "gateway.json"
def get_gateway_restart_notice_path(workspace: str | Path | None = None) -> Path:
return get_workspace_root(workspace) / "gateway-restart-notice.json"
def ensure_workspace(workspace: str | Path | None = None) -> Path:
"""Create the workspace if needed and return its root."""
root = get_workspace_root(workspace)
+4
View File
@@ -29,6 +29,10 @@ dependencies = [
"questionary>=2.0.1",
"watchfiles>=0.20.0",
"croniter>=2.0.0",
"slack-sdk>=3.0.0",
"python-telegram-bot>=21.0.0",
"discord.py>=2.0.0",
"lark-oapi>=1.5.0",
]
[project.optional-dependencies]
+61 -35
View File
@@ -40,8 +40,8 @@ for arg in "$@"; do
echo "Usage: $0 [--from-source] [--with-channels]"
echo ""
echo " --from-source Clone from GitHub and install in editable mode"
echo " --with-channels Also install IM channel dependencies"
echo " (slack-sdk, python-telegram-bot, discord.py)"
echo " --with-channels Deprecated compatibility flag."
echo " Common IM channel dependencies are installed by default."
exit 0
;;
*)
@@ -232,14 +232,11 @@ fi
success "OpenHarness package installed"
# ---------------------------------------------------------------------------
# Step 5: Install IM channel dependencies (--with-channels)
# Step 5: Channel dependencies
# ---------------------------------------------------------------------------
if [ "$WITH_CHANNELS" = true ]; then
step "Installing IM channel dependencies (--with-channels)"
CHANNEL_DEPS="slack-sdk python-telegram-bot discord.py"
info "Installing: ${CHANNEL_DEPS}"
$PIP_CMD install $CHANNEL_DEPS --quiet
success "Channel dependencies installed"
step "Channel dependencies"
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
fi
# ---------------------------------------------------------------------------
@@ -279,21 +276,23 @@ success "Config directory ready: ~/.openharness/"
# ---------------------------------------------------------------------------
step "Verifying installation"
if command -v oh &>/dev/null; then
if command -v oh &>/dev/null && command -v ohmo &>/dev/null; then
OH_VERSION=$(oh --version 2>&1 || echo "(version check failed)")
OHMO_VERSION=$(ohmo --help >/dev/null 2>&1 && echo "available" || echo "not available")
success "Installation successful!"
echo ""
echo -e " ${BOLD}oh${RESET} is ready: ${GREEN}${OH_VERSION}${RESET}"
echo -e " ${BOLD}ohmo${RESET} is ready: ${GREEN}${OHMO_VERSION}${RESET}"
elif "$PYTHON_CMD" -m openharness --version &>/dev/null 2>&1; then
OH_VERSION=$("$PYTHON_CMD" -m openharness --version 2>&1)
warn "'oh' not in PATH. Run via: python -m openharness"
warn "'oh'/'ohmo' not in PATH. Run via: python -m openharness or python -m ohmo"
echo " Version: ${OH_VERSION}"
echo " To add 'oh' to PATH, ensure your Python bin directory is in PATH:"
echo " export PATH=\"\$($PYTHON_CMD -m site --user-base)/bin:\$PATH\""
echo " To add them to PATH, ensure ${VENV_DIR}/bin is in PATH:"
echo " export PATH=\"${VENV_DIR}/bin:\$PATH\""
else
warn "Could not verify 'oh' command. The package may need a PATH update."
warn "Could not verify 'oh'/'ohmo' commands. The package may need a PATH update."
echo " Try: $PYTHON_CMD -m openharness --version"
echo " Or add Python's user bin to PATH and restart your shell."
echo " Or add ${VENV_DIR}/bin to PATH and restart your shell."
fi
# ---------------------------------------------------------------------------
@@ -301,27 +300,51 @@ fi
# ---------------------------------------------------------------------------
step "Setting up shell integration"
SHELL_RC=""
if [ -f "$HOME/.zshrc" ]; then
SHELL_RC="$HOME/.zshrc"
elif [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
elif [ -f "$HOME/.bash_profile" ]; then
SHELL_RC="$HOME/.bash_profile"
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
FISH_CONFIG="$HOME/.config/fish/config.fish"
FISH_BLOCK=$(cat <<EOF
# OpenHarness
if not contains -- "$VENV_DIR/bin" \$PATH
set -gx PATH "$VENV_DIR/bin" \$PATH
end
EOF
)
configured_any=false
append_shell_path() {
local rc_file="$1"
if [ ! -f "$rc_file" ]; then
return
fi
if grep -q "$VENV_DIR/bin" "$rc_file" 2>/dev/null; then
info "PATH already configured in $(basename "$rc_file")"
configured_any=true
return
fi
echo "" >> "$rc_file"
echo "# OpenHarness" >> "$rc_file"
echo "$ACTIVATION_LINE" >> "$rc_file"
success "Added $VENV_DIR/bin to PATH in $(basename "$rc_file")"
configured_any=true
}
append_shell_path "$HOME/.zshrc"
append_shell_path "$HOME/.bashrc"
append_shell_path "$HOME/.bash_profile"
mkdir -p "$(dirname "$FISH_CONFIG")"
if [ -f "$FISH_CONFIG" ] && grep -q "$VENV_DIR/bin" "$FISH_CONFIG" 2>/dev/null; then
info "PATH already configured in $(basename "$FISH_CONFIG")"
configured_any=true
else
echo "" >> "$FISH_CONFIG"
printf "%s\n" "$FISH_BLOCK" >> "$FISH_CONFIG"
success "Added $VENV_DIR/bin to PATH in $(basename "$FISH_CONFIG")"
configured_any=true
fi
ACTIVATION_LINE="export PATH=\"$VENV_DIR/bin:\$PATH\""
if [ -n "$SHELL_RC" ]; then
if ! grep -q "$VENV_DIR/bin" "$SHELL_RC" 2>/dev/null; then
echo "" >> "$SHELL_RC"
echo "# OpenHarness" >> "$SHELL_RC"
echo "$ACTIVATION_LINE" >> "$SHELL_RC"
success "Added $VENV_DIR/bin to PATH in $(basename $SHELL_RC)"
else
info "PATH already configured in $(basename $SHELL_RC)"
fi
else
if [ "$configured_any" = false ]; then
warn "Could not find shell config file. Add this to your shell profile:"
echo " $ACTIVATION_LINE"
fi
@@ -333,8 +356,11 @@ echo ""
echo -e "${BOLD}${GREEN}OpenHarness is installed!${RESET}"
echo ""
echo " Next steps:"
echo " 1. Restart shell (or run): source ${SHELL_RC:-~/.bashrc}"
echo " 1. Restart shell, or reload your shell config:"
echo " bash/zsh: source ~/.bashrc (or ~/.zshrc)"
echo " fish: source ~/.config/fish/config.fish"
echo " 2. Set your API key: export ANTHROPIC_API_KEY=your_key"
echo " 3. Launch: oh"
echo " 4. Docs: https://github.com/HKUDS/OpenHarness"
echo " 4. Launch ohmo: ohmo"
echo " 5. Docs: https://github.com/HKUDS/OpenHarness"
echo ""
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Developer install for the current checkout.
# Usage:
# bash scripts/install_dev.sh
# bash scripts/install_dev.sh --global-venv
# bash scripts/install_dev.sh --with-channels
set -euo pipefail
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' RESET=''
fi
info() { echo -e "${CYAN}[INFO]${RESET} $*"; }
success() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
step() { echo -e "\n${BOLD}${BLUE}==>${RESET}${BOLD} $*${RESET}"; }
WITH_CHANNELS=false
GLOBAL_VENV=false
for arg in "$@"; do
case "$arg" in
--with-channels) WITH_CHANNELS=true ;;
--global-venv) GLOBAL_VENV=true ;;
--help|-h)
echo "Usage: $0 [--with-channels] [--global-venv]"
echo ""
echo "Installs the current checkout in editable mode and"
echo "registers oh/ohmo in ~/.local/bin."
echo ""
echo " default use ./ .openharness-venv inside the current repo"
echo " --global-venv use ~/.openharness-venv but still install the current repo"
echo " --with-channels deprecated compatibility flag; common IM deps install by default"
exit 0
;;
*)
error "Unknown argument: $arg"
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ "$GLOBAL_VENV" = true ]; then
VENV_DIR="$HOME/.openharness-venv"
else
VENV_DIR="$REPO_ROOT/.openharness-venv"
fi
BIN_DIR="$HOME/.local/bin"
step "Checking Python version (>= 3.10 required)"
PYTHON_CMD=""
for cmd in python3 python; do
if command -v "$cmd" >/dev/null 2>&1; then
PY_VER=$("$cmd" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VER" | cut -d. -f2)
if [ "${PY_MAJOR}" -ge 3 ] && [ "${PY_MINOR}" -ge 10 ]; then
PYTHON_CMD="$cmd"
break
fi
fi
done
if [ -z "$PYTHON_CMD" ]; then
error "Python 3.10+ not found."
exit 1
fi
success "Found $("$PYTHON_CMD" --version 2>&1) (${PYTHON_CMD})"
step "Preparing developer virtual environment"
if [ ! -d "$VENV_DIR" ]; then
info "Creating virtual environment at ${VENV_DIR}"
"$PYTHON_CMD" -m venv "$VENV_DIR"
fi
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip setuptools wheel --quiet
success "Virtual environment ready: ${VENV_DIR}"
step "Installing current checkout in editable mode"
python -m pip install -e "$REPO_ROOT" --quiet
success "Installed OpenHarness from ${REPO_ROOT}"
if [ "$WITH_CHANNELS" = true ]; then
step "Channel dependencies"
info "--with-channels is no longer required; common IM channel dependencies are installed by default."
fi
step "Installing React terminal dependencies (optional)"
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version 2>&1 | grep -oE '[0-9]+' | head -1)
FRONTEND_DIR="$REPO_ROOT/frontend/terminal"
if [ "${NODE_MAJOR}" -ge 18 ] 2>/dev/null && [ -f "$FRONTEND_DIR/package.json" ]; then
info "Running npm install in ${FRONTEND_DIR}"
(cd "$FRONTEND_DIR" && npm install --no-fund --no-audit --silent)
success "React terminal dependencies installed"
else
warn "Node.js too old or frontend directory missing; skipping npm install"
fi
else
warn "Node.js not found; skipping npm install"
fi
step "Registering global commands"
mkdir -p "$BIN_DIR"
ln -snf "$VENV_DIR/bin/oh" "$BIN_DIR/oh"
ln -snf "$VENV_DIR/bin/ohmo" "$BIN_DIR/ohmo"
ln -snf "$VENV_DIR/bin/openharness" "$BIN_DIR/openharness"
success "Linked oh/ohmo into ${BIN_DIR}"
ensure_path_in_file() {
local rc_file="$1"
local line="$2"
[ -f "$rc_file" ] || return 0
if ! grep -qF "$line" "$rc_file" 2>/dev/null; then
echo "" >> "$rc_file"
echo "# OpenHarness dev" >> "$rc_file"
echo "$line" >> "$rc_file"
success "Added ${BIN_DIR} to PATH in $(basename "$rc_file")"
fi
}
step "Ensuring ~/.local/bin is on PATH"
mkdir -p "$HOME/.config/fish"
ensure_path_in_file "$HOME/.bashrc" "export PATH=\"$BIN_DIR:\$PATH\""
ensure_path_in_file "$HOME/.bash_profile" "export PATH=\"$BIN_DIR:\$PATH\""
ensure_path_in_file "$HOME/.zshrc" "export PATH=\"$BIN_DIR:\$PATH\""
if [ -f "$HOME/.config/fish/config.fish" ]; then
if ! grep -qF "$BIN_DIR" "$HOME/.config/fish/config.fish" 2>/dev/null; then
{
echo ""
echo "# OpenHarness dev"
echo "if not contains -- \"$BIN_DIR\" \$PATH"
echo " set -gx PATH \"$BIN_DIR\" \$PATH"
echo "end"
} >> "$HOME/.config/fish/config.fish"
success "Added ${BIN_DIR} to PATH in config.fish"
fi
else
cat > "$HOME/.config/fish/config.fish" <<EOF
# OpenHarness dev
if not contains -- "$BIN_DIR" \$PATH
set -gx PATH "$BIN_DIR" \$PATH
end
EOF
success "Created config.fish with ${BIN_DIR} on PATH"
fi
echo ""
echo -e "${BOLD}${GREEN}Developer install complete.${RESET}"
echo ""
echo " Repo root: $REPO_ROOT"
echo " Virtual environment: $VENV_DIR"
echo " Command links: $BIN_DIR/oh , $BIN_DIR/ohmo"
echo ""
echo " If this shell does not see the commands yet, run one of:"
echo " bash: source ~/.bashrc"
echo " zsh: source ~/.zshrc"
echo " fish: source ~/.config/fish/config.fish"
echo ""
echo " Or use immediately in this shell:"
echo " export PATH=\"$BIN_DIR:\$PATH\""
echo " hash -r"
echo ""
+13
View File
@@ -9,6 +9,7 @@ import os
import platform
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
@@ -26,6 +27,8 @@ class EnvironmentInfo:
home_dir: str
date: str
python_version: str
python_executable: str
virtual_env: str | None
is_git_repo: bool
git_branch: str | None = None
hostname: str = ""
@@ -103,6 +106,14 @@ def get_environment_info(cwd: str | None = None) -> EnvironmentInfo:
if cwd is None:
cwd = os.getcwd()
python_executable = str(Path(sys.executable).resolve())
virtual_env = os.environ.get("VIRTUAL_ENV")
if not virtual_env:
executable_path = Path(python_executable)
candidate = executable_path.parent.parent
if executable_path.parent.name in {"bin", "Scripts"} and (candidate / "pyvenv.cfg").exists():
virtual_env = str(candidate)
os_name, os_version = detect_os()
shell = detect_shell()
is_git, branch = detect_git_info(cwd)
@@ -116,6 +127,8 @@ def get_environment_info(cwd: str | None = None) -> EnvironmentInfo:
home_dir=str(Path.home()),
date=datetime.now(tz=timezone.utc).strftime("%Y-%m-%d"),
python_version=platform.python_version(),
python_executable=python_executable,
virtual_env=virtual_env,
is_git_repo=is_git,
git_branch=branch,
hostname=platform.node(),
+4
View File
@@ -70,8 +70,12 @@ def _format_environment_section(env: EnvironmentInfo) -> str:
f"- Working directory: {env.cwd}",
f"- Date: {env.date}",
f"- Python: {env.python_version}",
f"- Python executable: {env.python_executable}",
]
if env.virtual_env:
lines.append(f"- Virtual environment: {env.virtual_env}")
if env.is_git_repo:
git_line = "- Git: yes"
if env.git_branch:
+107 -2
View File
@@ -4,6 +4,7 @@ import logging
from types import SimpleNamespace
from datetime import datetime
import json
from pathlib import Path
import pytest
@@ -17,10 +18,10 @@ from openharness.engine.stream_events import AssistantTextDelta, StatusEvent, To
from ohmo.gateway.bridge import OhmoGatewayBridge, _format_gateway_error
from ohmo.gateway.models import GatewayState
from ohmo.gateway.runtime import OhmoSessionRuntimePool
from ohmo.gateway.service import gateway_status, stop_gateway_process
from ohmo.gateway.service import OhmoGatewayService, gateway_status, stop_gateway_process
from ohmo.gateway.router import session_key_for_message
from ohmo.session_storage import save_session_snapshot
from ohmo.workspace import initialize_workspace
from ohmo.workspace import get_gateway_restart_notice_path, initialize_workspace
def test_gateway_router_uses_thread_when_present():
@@ -431,6 +432,110 @@ async def test_gateway_bridge_stop_command_cancels_current_session():
assert stopped.content == "⏹️ 已停止当前正在运行的任务。"
@pytest.mark.asyncio
async def test_gateway_bridge_restart_command_requests_gateway_restart():
bus = MessageBus()
restarted = asyncio.Event()
restart_payloads: list[tuple[str, str, str]] = []
class FakeRuntimePool:
async def stream_message(self, message, session_key):
if False:
yield
async def fake_restart(message, session_key: str) -> None:
restart_payloads.append((message.channel, message.chat_id, session_key))
restarted.set()
bridge = OhmoGatewayBridge(bus=bus, runtime_pool=FakeRuntimePool(), restart_gateway=fake_restart)
task = asyncio.create_task(bridge.run())
try:
await bus.publish_inbound(
InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/restart")
)
restarting = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
await asyncio.wait_for(restarted.wait(), timeout=1.0)
finally:
bridge.stop()
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert restarting.content == (
"🔄 正在重启 gateway,马上回来。\n"
"Restarting the gateway now. I'll be back in a moment."
)
assert restart_payloads == [("feishu", "c1", "feishu:c1")]
@pytest.mark.asyncio
async def test_gateway_service_request_restart_waits_before_stop(monkeypatch):
service = object.__new__(OhmoGatewayService)
service._restart_requested = False
service._stop_event = asyncio.Event()
service._workspace = "/tmp/ohmo"
slept: list[float] = []
async def fake_sleep(delay: float) -> None:
slept.append(delay)
monkeypatch.setattr("ohmo.gateway.service.asyncio.sleep", fake_sleep)
monkeypatch.setattr(
"ohmo.gateway.service.get_gateway_restart_notice_path",
lambda workspace: Path("/tmp/restart-notice.json"),
)
writes: list[str] = []
monkeypatch.setattr(
"pathlib.Path.write_text",
lambda self, content, encoding=None: writes.append(content) or len(content),
)
message = InboundMessage(channel="feishu", sender_id="u1", chat_id="c1", content="/restart")
await OhmoGatewayService.request_restart(service, message, "feishu:c1")
assert service._restart_requested is True
assert service._stop_event.is_set() is True
assert slept == [0.75]
assert writes
@pytest.mark.asyncio
async def test_gateway_service_publishes_pending_restart_notice(tmp_path, monkeypatch):
workspace = tmp_path / ".ohmo-home"
initialize_workspace(workspace)
notice_path = get_gateway_restart_notice_path(workspace)
notice_path.write_text(
json.dumps(
{
"channel": "feishu",
"chat_id": "chat-1",
"session_key": "feishu:chat-1",
"content": "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue.",
},
ensure_ascii=False,
),
encoding="utf-8",
)
service = object.__new__(OhmoGatewayService)
service._workspace = workspace
service._bus = MessageBus()
async def fake_sleep(delay: float) -> None:
return None
monkeypatch.setattr("ohmo.gateway.service.asyncio.sleep", fake_sleep)
await OhmoGatewayService._publish_pending_restart_notice(service)
outbound = await asyncio.wait_for(service._bus.consume_outbound(), timeout=1.0)
assert outbound.content == "✅ gateway 已经重新连上,可以继续了。\nGateway is back online. We can continue."
assert outbound.chat_id == "chat-1"
assert not notice_path.exists()
@pytest.mark.asyncio
async def test_gateway_bridge_new_message_interrupts_same_session():
bus = MessageBus()
+18
View File
@@ -87,6 +87,24 @@ def test_get_environment_info_returns_dataclass():
assert len(info.cwd) > 0
assert len(info.date) == 10 # YYYY-MM-DD
assert len(info.python_version) > 0
assert len(info.python_executable) > 0
def test_get_environment_info_detects_virtual_env_from_python_executable(monkeypatch, tmp_path: Path):
venv_root = tmp_path / ".openharness-venv"
bin_dir = venv_root / "bin"
bin_dir.mkdir(parents=True)
(venv_root / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding="utf-8")
fake_python = bin_dir / "python"
fake_python.write_text("", encoding="utf-8")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr("openharness.prompts.environment.sys.executable", str(fake_python))
info = get_environment_info(cwd=str(tmp_path))
assert info.python_executable == str(fake_python.resolve())
assert info.virtual_env == str(venv_root.resolve())
def test_get_environment_info_cwd_override(tmp_path: Path):
+4
View File
@@ -16,6 +16,8 @@ def _make_env(**overrides) -> EnvironmentInfo:
home_dir="/home/user",
date="2026-04-01",
python_version="3.10.17",
python_executable="/home/user/.openharness-venv/bin/python",
virtual_env="/home/user/.openharness-venv",
is_git_repo=True,
git_branch="main",
hostname="testhost",
@@ -33,6 +35,8 @@ def test_build_system_prompt_contains_environment():
assert "/home/user/project" in prompt
assert "2026-04-01" in prompt
assert "3.10.17" in prompt
assert "/home/user/.openharness-venv/bin/python" in prompt
assert "Virtual environment: /home/user/.openharness-venv" in prompt
assert "branch: main" in prompt