diff --git a/flocks/cli/commands/doctor.py b/flocks/cli/commands/doctor.py
index e6e9a9dec..940561006 100644
--- a/flocks/cli/commands/doctor.py
+++ b/flocks/cli/commands/doctor.py
@@ -2,8 +2,8 @@
from __future__ import annotations
+import asyncio
import os
-import shlex
import shutil
import subprocess
import sys
@@ -18,14 +18,12 @@
def doctor_command() -> None:
- """Run the source installer from the Flocks source directory."""
+ """Repair the active source installation using the shared core installer."""
source_root = _find_source_root()
- script = _select_source_install_script(source_root)
- command = _build_source_install_command(script)
env = _build_source_install_env()
console.print(f"[cyan]Flocks source directory:[/cyan] {source_root}")
- console.print(f"[cyan]Source install command:[/cyan] {_format_command(command)}")
+ console.print("[cyan]Repairing Flocks core installation...[/cyan]")
if _needs_windows_handoff():
_start_windows_handoff(source_root, env=env)
@@ -35,12 +33,41 @@ def doctor_command() -> None:
)
return
- _run_source_install(command, source_root=source_root, env=env)
+ _run_core_install(source_root, env=env)
console.print("[green]安装正常[/green]")
_print_service_diagnosis()
+def _run_core_install(source_root: Path, *, env: dict[str, str] | None = None) -> None:
+ """Run the shared updater/doctor core installation entry point."""
+ from flocks.updater import updater
+
+ uv_path = shutil.which("uv")
+ if not uv_path:
+ script_name = "install.ps1" if _is_windows() else "install.sh"
+ console.print(
+ f"[red]uv was not found. Run scripts/{script_name} to install system prerequisites.[/red]"
+ )
+ raise typer.Exit(1)
+
+ install_env = env or os.environ
+ try:
+ asyncio.run(
+ updater.install_or_repair_source(
+ install_root=source_root,
+ uv_path=uv_path,
+ version=updater.get_current_version(),
+ uv_default_index=install_env.get("FLOCKS_UV_DEFAULT_INDEX"),
+ npm_registry=install_env.get("FLOCKS_NPM_REGISTRY"),
+ sync_timeout=300,
+ )
+ )
+ except RuntimeError as error:
+ console.print(f"[red]Core installation repair failed: {error}[/red]")
+ raise typer.Exit(1) from error
+
+
def _find_source_root(start: Path | None = None) -> Path:
"""Find the repository root that owns the source install scripts."""
current = (start or Path(__file__)).resolve()
@@ -55,44 +82,6 @@ def _find_source_root(start: Path | None = None) -> Path:
raise typer.BadParameter("Could not locate the Flocks source directory.")
-def _select_source_install_script(source_root: Path) -> Path:
- """Select the platform-specific source install script."""
- suffix = ".ps1" if _is_windows() else ".sh"
- script = source_root / "scripts" / f"install{suffix}"
-
- if not script.is_file():
- raise typer.BadParameter(f"Source installer not found: {script}")
-
- return script
-
-
-def _build_source_install_command(script: Path) -> list[str]:
- """Build the subprocess command for the selected installer."""
- if script.suffix == ".ps1":
- powershell = _find_powershell()
- return [
- powershell,
- "-NoProfile",
- "-ExecutionPolicy",
- "Bypass",
- "-File",
- str(script),
- ]
-
- return ["bash", str(script)]
-
-
-def _run_source_install(command: list[str], *, source_root: Path, env: dict[str, str] | None) -> None:
- """Run the selected source installer synchronously."""
- try:
- subprocess.run(command, cwd=source_root, check=True, env=env)
- except FileNotFoundError as error:
- console.print(f"[red]Failed to start installer: {error}[/red]")
- raise typer.Exit(1) from error
- except subprocess.CalledProcessError as error:
- raise typer.Exit(error.returncode or 1) from error
-
-
def _build_source_install_env() -> dict[str, str] | None:
"""Build installer environment from the persisted install language."""
if not is_cn_install_language():
@@ -195,11 +184,6 @@ def _service_status_is_healthy(status_lines: list[str]) -> bool:
return backend_running and webui_running
-def _format_command(command: list[str]) -> str:
- """Return a shell-readable representation of the command."""
- return " ".join(shlex.quote(part) for part in command)
-
-
def _is_windows() -> bool:
"""Return whether the current platform should use PowerShell installers."""
return sys.platform.startswith("win")
diff --git a/flocks/cli/commands/update.py b/flocks/cli/commands/update.py
index c288b9755..fd9978bb7 100644
--- a/flocks/cli/commands/update.py
+++ b/flocks/cli/commands/update.py
@@ -35,7 +35,7 @@ def update_command(
async def _update(check: bool, yes: bool, force: bool = False, region: str | None = None) -> None:
- from flocks.updater import build_updated_frontend, check_update, perform_update, detect_deploy_mode
+ from flocks.updater import check_update, detect_deploy_mode, perform_update
from flocks.cli.install_profile import is_cn_install_language
if region is None and is_cn_install_language():
@@ -97,26 +97,15 @@ async def _load_update_info(selected_region: str | None):
console.print("[yellow]已取消[/yellow]")
return
- from flocks.cli.service_manager import ServiceError, stop_all
-
- try:
- stop_all(console)
- except ServiceError as error:
- console.print(f"[red]执行 flocks stop 失败:{error}[/red]")
- raise typer.Exit(1)
-
- append_upgrade_text_log(f"OK cli_update_stopped_services_before_upgrade version={version_to_apply}")
- console.print("[green]✓ 已执行 flocks stop[/green]")
console.print()
stage_labels = {
"fetching": "下载最新源码包",
"backing_up": "备份当前版本",
- "applying": f"应用 v{info.latest_version}",
- "syncing": "同步依赖",
+ "applying": "应用新版本",
"restarting": "重启服务",
"done": "完成",
}
- total_steps = 6
+ total_steps = 3
seen_stages: set[str] = set()
step = 0
active_stage: str | None = None
@@ -137,8 +126,9 @@ def _finish_active(success: bool = True) -> None:
tarball_url=info.tarball_url,
bundle_sha256=info.bundle_sha256,
bundle_format=info.bundle_format,
- restart=False,
+ restart=True,
region=region,
+ wait_for_handoff=True,
):
if progress.stage == "error":
_finish_active(success=False)
@@ -149,6 +139,9 @@ def _finish_active(success: bool = True) -> None:
_finish_active(success=True)
continue
+ if progress.stage == "restarting":
+ continue
+
if progress.stage not in seen_stages:
_finish_active(success=True)
seen_stages.add(progress.stage)
@@ -157,23 +150,8 @@ def _finish_active(success: bool = True) -> None:
console.print(f"[cyan][{step}/{total_steps}] {label}...[/cyan] ", end="")
active_stage = progress.stage
- step += 1
- console.print(f"[cyan][{step}/{total_steps}] 构建前端...[/cyan] ", end="")
- try:
- await build_updated_frontend(region=region)
- except Exception as error:
- console.print("[red]✗[/red]")
- console.print(f"\n[red]✗ 前端构建失败:{error}[/red]")
- raise typer.Exit(1)
- console.print("[green]✓[/green]")
-
- step += 1
- console.print(f"[cyan][{step}/{total_steps}] 完成[/cyan] ", end="")
- console.print("[green]✓[/green]")
-
append_upgrade_text_log(f"OK cli_update_completed version={version_to_apply}")
console.print(f"\n[green]✓ 升级完成 → v{version_to_apply}[/green]")
- console.print("[dim]如需恢复服务,请执行 [bold]flocks start[/bold][/dim]")
def _print_version_table(info) -> None:
diff --git a/flocks/cli/service_control.py b/flocks/cli/service_control.py
index 34320fa9f..50bd7ae74 100644
--- a/flocks/cli/service_control.py
+++ b/flocks/cli/service_control.py
@@ -183,23 +183,6 @@ def request_restart_webui(
return parse_supervisor_status(data)
-def request_prepare_upgrade(*, paths=None, timeout: float | None = 30.0) -> SupervisorStatus:
- """Ask the supervisor daemon to pause managed services for upgrade handoff."""
- payload = _post_control_json("/upgrade/prepare", paths=paths, timeout=timeout)
- return parse_supervisor_status(payload)
-
-
-def request_resume_upgrade(
- config: ServiceConfig,
- *,
- paths=None,
- timeout: float | None = 180.0,
-) -> SupervisorStatus:
- """Ask the supervisor daemon to resume managed services after upgrade handoff."""
- payload = _post_control_json("/upgrade/resume", payload=service_config_payload(config), paths=paths, timeout=timeout)
- return parse_supervisor_status(payload)
-
-
def read_logs(
*,
service: str,
diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py
index 46134d52f..bf6aeb03c 100644
--- a/flocks/cli/service_manager.py
+++ b/flocks/cli/service_manager.py
@@ -89,21 +89,6 @@ class RuntimeRecord:
started_at: float | None = None
-@dataclass(frozen=True)
-class UpgradeRuntimeInfo:
- payload_present: bool = False
- pid_file_present: bool = False
- upgrade_pid: int | None = None
- frontend_host: str | None = None
- frontend_port: int | None = None
- listener_pids: tuple[int, ...] = ()
- page_active: bool = False
-
- @property
- def has_artifacts(self) -> bool:
- return self.payload_present or self.pid_file_present
-
-
def repo_root() -> Path:
"""Return the installed repository root."""
override = os.getenv("FLOCKS_REPO_ROOT")
@@ -723,51 +708,6 @@ def _console_print(console, message: str) -> None:
console.print(message)
-def _read_upgrade_runtime_info(frontend_port: int | None = None) -> UpgradeRuntimeInfo:
- try:
- from flocks.updater import updater as updater_module
-
- payload = updater_module.read_upgrade_runtime_state(frontend_port=frontend_port)
- except Exception:
- return UpgradeRuntimeInfo(frontend_port=frontend_port)
-
- listener_pids = tuple(int(pid) for pid in payload.get("listener_pids", []) if isinstance(pid, int))
- return UpgradeRuntimeInfo(
- payload_present=bool(payload.get("payload_present")),
- pid_file_present=bool(payload.get("pid_file_present")),
- upgrade_pid=payload.get("upgrade_pid") if isinstance(payload.get("upgrade_pid"), int) else None,
- frontend_host=payload.get("frontend_host") if isinstance(payload.get("frontend_host"), str) else None,
- frontend_port=payload.get("frontend_port") if isinstance(payload.get("frontend_port"), int) else frontend_port,
- listener_pids=listener_pids,
- page_active=bool(payload.get("page_active")),
- )
-
-
-def _resolve_upgrade_runtime(console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]:
- upgrade_info = _read_upgrade_runtime_info(frontend_port)
- if not upgrade_info.has_artifacts:
- return {"action": "noop", "error": None}
-
- from flocks.updater import updater as updater_module
-
- _console_print(console, "[flocks] 检测到升级临时页残留,正在尝试恢复或清理...")
- result = updater_module.resolve_upgrade_runtime_state(
- attempt_recover=attempt_recover,
- frontend_port=upgrade_info.frontend_port or frontend_port,
- )
-
- action = str(result.get("action") or "noop")
- error = result.get("error")
- if action == "recovered":
- _console_print(console, "[flocks] 已恢复未完成升级,正式 WebUI 将继续接管端口。")
- elif action != "noop":
- _console_print(console, "[flocks] 已清理升级临时页残留。")
-
- if isinstance(error, str) and error:
- _console_print(console, f"[flocks] 未完成升级的自动恢复失败,已清理临时升级页: {error}")
- return result
-
-
def cleanup_stale_pid_file(pid_file: Path) -> None:
"""Remove pid files that no longer point to running processes."""
if not pid_file.exists():
@@ -1569,7 +1509,6 @@ def _start_all_without_stop(config: ServiceConfig, console) -> None:
def _start_all_unlocked(config: ServiceConfig, console, *, paths: RuntimePaths) -> None:
"""Ensure the supervisor daemon is running; caller must hold lifecycle lock."""
- _resolve_upgrade_runtime(console, frontend_port=config.frontend_port, attempt_recover=False)
if supervisor_is_running(paths):
status = None
try:
diff --git a/flocks/cli/service_supervisor.py b/flocks/cli/service_supervisor.py
index e94397300..4a615b59a 100644
--- a/flocks/cli/service_supervisor.py
+++ b/flocks/cli/service_supervisor.py
@@ -119,8 +119,6 @@ def __init__(
self._shutdown_requested = threading.Event()
self._server: ThreadingHTTPServer | None = None
self._server_thread: threading.Thread | None = None
- self._backend_paused = False
- self._webui_paused = False
self.backend = ManagedService(
name="backend",
label="后端",
@@ -277,15 +275,6 @@ def do_POST(self) -> None:
if parsed.path == "/stop/webui":
self._send_json({"error": "static WebUI is served by Flocks service and cannot be stopped separately"}, status=409)
return
- if parsed.path == "/upgrade/prepare":
- daemon.prepare_upgrade(reason="control upgrade prepare")
- self._send_json(daemon.status_payload())
- return
- if parsed.path == "/upgrade/resume":
- daemon.update_config(payload)
- daemon.resume_upgrade(reason="control upgrade resume")
- self._send_json(daemon.status_payload())
- return
self._send_json({"error": "not found"}, status=404)
except _CLIENT_DISCONNECT_ERRORS:
return
@@ -320,8 +309,8 @@ def status_payload(self) -> dict[str, object]:
"state": "stopping" if self._shutdown_requested.is_set() else "running",
"log_path": str(supervisor_log_path(self.paths)),
},
- "backend": _service_payload(self.backend, paused=self._backend_paused),
- "webui": _service_payload(self.webui, paused=self._webui_paused),
+ "backend": _service_payload(self.backend),
+ "webui": _service_payload(self.webui),
"config": service_config_payload(self.config),
}
@@ -411,22 +400,18 @@ def _log_paths_for_service(self, service_name: str) -> list[tuple[str, Path]]:
def restart_all(self, *, reason: str) -> None:
with self._lock:
- self._backend_paused = False
- self._webui_paused = False
self._restart_service(self.backend, reason=reason, immediate=True)
self._start_backend_locked(immediate=True)
self._sync_static_webui_state()
def restart_backend(self, *, reason: str) -> None:
with self._lock:
- self._backend_paused = False
self._restart_service(self.backend, reason=reason, immediate=True)
self._start_backend_locked(immediate=True)
self._sync_static_webui_state()
def restart_webui(self, *, reason: str, force_frontend_build: bool = False) -> None:
with self._lock:
- self._webui_paused = False
if force_frontend_build:
from flocks.cli.service_config import with_frontend_build
@@ -435,27 +420,6 @@ def restart_webui(self, *, reason: str, force_frontend_build: bool = False) -> N
self._start_backend_locked(immediate=True)
self._sync_static_webui_state()
- def prepare_upgrade(self, *, reason: str) -> None:
- with self._lock:
- self._backend_paused = True
- self._webui_paused = True
- _daemon_log("service_pause", {"service": "backend", "reason": reason})
- _daemon_log("service_pause", {"service": "webui", "reason": reason})
- self.backend.last_error = reason
- self.webui.last_error = reason
- self._stop_service(self.backend)
- self.webui.state = "paused"
-
- def resume_upgrade(self, *, reason: str) -> None:
- with self._lock:
- self._backend_paused = False
- self._webui_paused = False
- _daemon_log("service_resume", {"service": "backend", "reason": reason})
- _daemon_log("service_resume", {"service": "webui", "reason": reason})
- self._probe_backend_locked()
- self._start_backend_locked(immediate=True)
- self._sync_static_webui_state()
-
def shutdown_children(self) -> None:
with self._lock:
self._stop_service(self.backend)
@@ -463,10 +427,8 @@ def shutdown_children(self) -> None:
def tick(self) -> None:
with self._lock:
- if not self._backend_paused:
- self._probe_backend_locked()
- if not self._backend_paused:
- self._start_backend_locked(immediate=False)
+ self._probe_backend_locked()
+ self._start_backend_locked(immediate=False)
self._sync_static_webui_state()
def _restart_service(self, service: ManagedService, *, reason: str, immediate: bool) -> None:
@@ -547,9 +509,6 @@ def _sync_static_webui_state(self) -> None:
self.webui.log_path = self.paths.backend_log
self.webui.process = None
self.webui.command = ()
- if self._webui_paused:
- self.webui.state = "paused"
- return
if self.backend.state == "healthy":
self.webui.state = "static"
self.webui.last_error = None
diff --git a/flocks/server/app.py b/flocks/server/app.py
index 78271d130..770959016 100644
--- a/flocks/server/app.py
+++ b/flocks/server/app.py
@@ -472,18 +472,6 @@ async def _delayed_trigger_runtime_start() -> None:
except Exception as e:
log.warning("workflow.trigger_runtime.start_failed", {"error": str(e)})
- try:
- from flocks.updater.updater import recover_upgrade_state
-
- await _run_startup_phase(
- log,
- "updater.recover_upgrade_state",
- lambda: asyncio.to_thread(recover_upgrade_state),
- )
- log.info("updater.recovery.checked")
- except Exception as e:
- log.warning("updater.recovery.failed", {"error": str(e)})
-
blocking_startup_ms = int((time.perf_counter() - startup_started_at) * 1000)
log.info("server.startup.ready", {
"blocking_duration_ms": blocking_startup_ms,
diff --git a/flocks/server/routes/console_upgrade.py b/flocks/server/routes/console_upgrade.py
index 107140f35..93b74dfb2 100644
--- a/flocks/server/routes/console_upgrade.py
+++ b/flocks/server/routes/console_upgrade.py
@@ -525,11 +525,18 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]:
final_stage = ""
final_message = ""
- async for progress in perform_pro_bundle_install(restart=False):
+ async for progress in perform_pro_bundle_install(restart=True):
final_stage = progress.stage
final_message = progress.message
if progress.stage == "error":
raise ValueError(progress.message)
+ if progress.stage == "restarting":
+ details["auto_install_result"] = "restarting"
+ details["auto_install_message"] = progress.message
+ record["updated_at"] = datetime.now(UTC).isoformat()
+ request_id = str(record.get("request_id") or "").strip()
+ if request_id:
+ await Storage.set(_request_key(request_id), record, "json")
await _maybe_activate_pro_license(record, allow_fallback=False)
await _maybe_refresh_pro_license(record)
diff --git a/flocks/updater/__init__.py b/flocks/updater/__init__.py
index 0ba8cc329..512b9d4b6 100644
--- a/flocks/updater/__init__.py
+++ b/flocks/updater/__init__.py
@@ -2,8 +2,8 @@
Flocks Updater
Provides self-update capability via GitHub releases.
-Downloads source archives, backs up the current installation,
-and replaces source files — no git binary required at runtime.
+Downloads source archives and delegates source replacement to a detached
+handoff — no git binary required at runtime.
"""
from flocks.updater.deploy import DeployMode, detect_deploy_mode
@@ -13,6 +13,7 @@
check_update,
get_current_version,
get_latest_release,
+ install_or_repair_source,
perform_pro_bundle_downgrade,
perform_update,
perform_pro_bundle_install,
@@ -28,6 +29,7 @@
"check_update",
"get_current_version",
"get_latest_release",
+ "install_or_repair_source",
"perform_update",
"perform_pro_bundle_install",
"perform_pro_bundle_downgrade",
diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py
index 0d429a4db..f763d8970 100644
--- a/flocks/updater/restart_handoff.py
+++ b/flocks/updater/restart_handoff.py
@@ -1,16 +1,10 @@
-"""Restart handoff helper for the self-updater.
-
-The updater process owns the backend port while it is spawning the restart
-command. Starting the new backend before that process has fully exited can race
-with port release. This helper is spawned instead; it waits for the old backend
-to exit, clears any remaining backend listener, runs post-apply upgrade tasks,
-and then starts the real restart command.
-"""
+"""Detached restart and source-upgrade handoff helper."""
from __future__ import annotations
import argparse
import asyncio
+import json
import shutil
import subprocess
import time
@@ -18,6 +12,7 @@
from typing import Sequence
from flocks.cli import service_manager
+from flocks.updater import updater as updater_module
from flocks.utils.log import append_upgrade_text_log
DEFAULT_PARENT_TIMEOUT_SECONDS = 20.0
@@ -27,6 +22,13 @@
DEFAULT_POLL_INTERVAL_SECONDS = 0.25
+class _NullConsole:
+ """Discard service-manager progress output in the detached helper."""
+
+ def print(self, *_args, **_kwargs) -> None:
+ return None
+
+
def _record_handoff_log(message: str) -> None:
append_upgrade_text_log(f"restart_handoff {message}")
@@ -74,50 +76,73 @@ def _ensure_backend_port_free(backend_port: int) -> bool:
def _stop_supervisor_before_restart(
*,
+ daemon_pid: int | None = None,
+ backend_port: int | None = None,
+ service_ports: Sequence[int] = (),
+ force_daemon_stop: bool = False,
timeout_seconds: float = SUPERVISOR_STOP_TIMEOUT_SECONDS,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
) -> bool:
from flocks.cli import service_control
paths = service_manager.runtime_paths()
- if not service_control.supervisor_is_running(paths):
- return True
-
- try:
- service_control.request_stop(paths=paths, timeout=timeout_seconds)
- except Exception as exc:
- _record_handoff_log(f"supervisor_stop_request_failed error={exc}")
- return False
+ ports = {port for port in (backend_port, *service_ports) if port is not None}
+ control_running = service_control.supervisor_is_running(paths)
+ daemon_running = service_manager.pid_is_running(daemon_pid)
+ if control_running or daemon_running:
+ try:
+ service_control.request_stop(paths=paths, timeout=timeout_seconds)
+ except Exception as exc:
+ _record_handoff_log(f"supervisor_stop_request_failed error={exc}")
+ if not force_daemon_stop or daemon_pid is None:
+ return False
+ try:
+ service_manager._terminate_orphan_pid(daemon_pid, "daemon", _NullConsole())
+ except Exception as terminate_exc:
+ _record_handoff_log(f"supervisor_force_stop_failed error={terminate_exc}")
+ return False
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
- if not service_control.supervisor_is_running(paths):
+ control_stopped = not service_control.supervisor_is_running(paths)
+ daemon_stopped = not service_manager.pid_is_running(daemon_pid)
+ ports_stopped = all(not _backend_port_in_use(port) for port in ports)
+ if control_stopped and daemon_stopped and ports_stopped:
return True
time.sleep(poll_interval_seconds)
- return not service_control.supervisor_is_running(paths)
+ return (
+ not service_control.supervisor_is_running(paths)
+ and not service_manager.pid_is_running(daemon_pid)
+ and all(not _backend_port_in_use(port) for port in ports)
+ )
def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Flocks restart handoff helper")
- parser.add_argument("--parent-pid", type=int, required=True)
+ parser.add_argument("--mode", choices=("restart", "upgrade"), default="restart")
+ parser.add_argument("--parent-pid", type=int)
parser.add_argument("--backend-host", required=True)
parser.add_argument("--backend-port", type=int, required=True)
parser.add_argument("--frontend-host", required=True)
parser.add_argument("--frontend-port", type=int, required=True)
parser.add_argument("--backend-pid-file")
parser.add_argument("--install-root", required=True)
+ parser.add_argument("--content-root")
+ parser.add_argument("--backup-path")
+ parser.add_argument("--was-running", action="store_true")
+ parser.add_argument("--daemon-pid", type=int)
+ parser.add_argument("--service-config-json")
parser.add_argument("--uv-path", required=True)
parser.add_argument("--sync-timeout", type=int, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--current-version", required=True)
- parser.add_argument("--backup-path")
parser.add_argument("--uv-default-index")
parser.add_argument("--npm-registry")
parser.add_argument("--pro-wheel-path")
parser.add_argument("--pro-bundle-manifest-path")
parser.add_argument("--bundle-sha256")
parser.add_argument("--cleanup-dir")
- parser.add_argument("--prepare-handover", action="store_true")
+ parser.add_argument("--prepare-handover", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("restart_argv", nargs=argparse.REMAINDER)
args = parser.parse_args(argv)
if args.restart_argv and args.restart_argv[0] == "--":
@@ -126,23 +151,177 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
def _run_upgrade_tasks(args: argparse.Namespace) -> str | None:
- from flocks.updater import updater
-
- return asyncio.run(
- updater.run_handoff_upgrade_tasks(
- install_root=Path(args.install_root),
- uv_path=args.uv_path,
- version=args.version,
- uv_default_index=args.uv_default_index,
- npm_registry=args.npm_registry,
- pro_wheel_path=Path(args.pro_wheel_path) if args.pro_wheel_path else None,
- pro_bundle_manifest_path=(
- Path(args.pro_bundle_manifest_path) if args.pro_bundle_manifest_path else None
- ),
- bundle_sha256=args.bundle_sha256,
- sync_timeout=args.sync_timeout,
+ try:
+ asyncio.run(
+ updater_module.install_or_repair_source(
+ install_root=Path(args.install_root),
+ uv_path=args.uv_path,
+ version=args.version,
+ uv_default_index=args.uv_default_index,
+ npm_registry=args.npm_registry,
+ pro_wheel_path=Path(args.pro_wheel_path) if args.pro_wheel_path else None,
+ pro_bundle_manifest_path=(
+ Path(args.pro_bundle_manifest_path) if args.pro_bundle_manifest_path else None
+ ),
+ bundle_sha256=args.bundle_sha256,
+ sync_timeout=args.sync_timeout,
+ )
)
+ except RuntimeError as exc:
+ return str(exc)
+ return None
+
+
+def _service_config_from_args(args: argparse.Namespace) -> service_manager.ServiceConfig:
+ """Load the captured service config without consulting the old daemon."""
+ from flocks.cli.service_config import service_config_from_payload
+
+ try:
+ payload = json.loads(args.service_config_json or "")
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid service config JSON: {exc}") from exc
+ if not isinstance(payload, dict):
+ raise ValueError("service config JSON must contain an object")
+ return service_config_from_payload(payload)
+
+
+def _validate_simple_upgrade_args(args: argparse.Namespace) -> None:
+ """Validate all inputs before stopping or changing the active install."""
+ if not args.content_root:
+ raise ValueError("upgrade content root is missing")
+ content_root = Path(args.content_root)
+ if not content_root.is_dir():
+ raise ValueError(f"upgrade content root does not exist: {content_root}")
+ if not args.backup_path:
+ raise ValueError("upgrade backup path is missing")
+ backup_path = Path(args.backup_path)
+ if not backup_path.is_file():
+ raise ValueError(f"upgrade backup does not exist: {backup_path}")
+ if args.was_running and not args.restart_argv:
+ raise ValueError("running service requires a restart runtime")
+ _service_config_from_args(args)
+
+
+def _service_ports(args: argparse.Namespace) -> tuple[int, ...]:
+ """Return every port that must be released before source replacement."""
+ config = _service_config_from_args(args)
+ return tuple(sorted({config.backend_port, config.frontend_port}))
+
+
+def _stop_services_before_upgrade(args: argparse.Namespace) -> bool:
+ """Stop managed services and wait for the captured daemon and port to exit."""
+ try:
+ service_manager.stop_all(_NullConsole())
+ except service_manager.ServiceError as exc:
+ _record_handoff_log(f"service_stop_failed error={exc}")
+ return False
+ return _stop_supervisor_before_restart(
+ daemon_pid=args.daemon_pid,
+ backend_port=args.backend_port,
+ service_ports=_service_ports(args),
+ )
+
+
+def _apply_new_source(args: argparse.Namespace) -> None:
+ """Replace the active source tree with the staged source tree."""
+ if not args.content_root:
+ raise RuntimeError("upgrade content root is missing")
+ content_root = Path(args.content_root)
+ if not content_root.is_dir():
+ raise RuntimeError(f"upgrade content root does not exist: {content_root}")
+ updater_module._replace_install_dir(content_root, Path(args.install_root))
+
+
+def _build_captured_start_argv(args: argparse.Namespace) -> list[str]:
+ """Build ``flocks start`` directly from the pre-upgrade config snapshot."""
+ if not args.restart_argv:
+ return []
+ config = _service_config_from_args(args)
+ argv = [
+ args.restart_argv[0],
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--host",
+ config.frontend_host,
+ "--port",
+ str(config.frontend_port),
+ ]
+ if config.no_browser:
+ argv.append("--no-browser")
+ if config.skip_frontend_build:
+ argv.append("--skip-webui-build")
+ if config.legacy_backend_host is not None:
+ argv.extend(["--server-host", config.legacy_backend_host])
+ if config.legacy_backend_port is not None:
+ argv.extend(["--server-port", str(config.legacy_backend_port)])
+ return argv
+
+
+def _start_service_after_upgrade(args: argparse.Namespace) -> tuple[bool, str, str]:
+ """Synchronously restore the service only when it ran before the upgrade."""
+ if not args.was_running:
+ return True, "", ""
+ start_argv = _build_captured_start_argv(args)
+ if not start_argv:
+ _record_handoff_log("missing_restart_argv")
+ return False, "", "missing restart runtime"
+ completed = subprocess.run(
+ start_argv,
+ cwd=Path(args.install_root),
+ capture_output=True,
+ text=False,
+ check=False,
+ )
+ stdout = updater_module._clean_process_output(completed.stdout)
+ stderr = updater_module._clean_process_output(completed.stderr)
+ if completed.returncode == 0:
+ return True, stdout, stderr
+ _record_handoff_log(
+ f"restart_failed returncode={completed.returncode} stdout={stdout} stderr={stderr}"
)
+ return False, stdout, stderr
+
+
+def _write_upgrade_result(
+ *,
+ args: argparse.Namespace,
+ phase: str,
+ failed_stage: str | None = None,
+ error: str | None = None,
+ backup_path: Path | None = None,
+ stdout: str | None = None,
+ stderr: str | None = None,
+) -> None:
+ """Persist a result record without driving automatic recovery."""
+ try:
+ config_payload = json.loads(args.service_config_json or "{}")
+ except json.JSONDecodeError:
+ config_payload = {}
+ payload = {
+ "phase": phase,
+ "version": args.version,
+ "current_version": args.current_version,
+ "was_running": args.was_running,
+ "service_config": config_payload,
+ }
+ if error:
+ payload["last_error"] = error
+ if failed_stage:
+ payload["failed_stage"] = failed_stage
+ if backup_path is not None:
+ payload["backup_path"] = str(backup_path)
+ if stdout:
+ payload["stdout"] = stdout
+ if stderr:
+ payload["stderr"] = stderr
+ try:
+ updater_module._write_upgrade_result_state(payload)
+ except Exception as exc:
+ try:
+ _record_handoff_log(f"upgrade_result_write_failed phase={phase} error={exc}")
+ except Exception:
+ pass
def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None:
@@ -161,39 +340,86 @@ def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None
_record_handoff_log("install_receipt_report_skipped")
-def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None:
- from flocks.updater import updater
-
- _record_handoff_log(f"upgrade_tasks_failed error={error}")
- backup_path = Path(args.backup_path) if args.backup_path else None
+def _run_simple_upgrade(args: argparse.Namespace) -> int:
+ """Run stop, source replacement, installation, and restart in order."""
try:
- updater._rollback_failed_update(
- backup_path,
- Path(args.install_root),
- args.current_version,
+ _validate_simple_upgrade_args(args)
+ except ValueError as exc:
+ error = str(exc)
+ _record_handoff_log(f"upgrade_validation_failed error={error}")
+ _write_upgrade_result(
+ args=args,
+ phase="failed",
+ failed_stage="validation",
+ error=error,
)
- except Exception as exc:
- _record_handoff_log(f"rollback_failed error={exc}")
+ _cleanup_dir(args.cleanup_dir)
+ return 1
+
+ if args.parent_pid is not None and not _wait_for_parent_exit(args.parent_pid):
+ error = f"parent exit timed out: {args.parent_pid}"
+ _record_handoff_log(error)
+ _write_upgrade_result(args=args, phase="failed", failed_stage="wait_parent", error=error)
+ return 1
+
+ _write_upgrade_result(args=args, phase="running")
+ if not _stop_services_before_upgrade(args):
+ error = "service stop timed out"
+ _record_handoff_log(error)
+ _write_upgrade_result(args=args, phase="failed", failed_stage="stop", error=error)
+ return 1
-def _prepare_upgrade_handover(args: argparse.Namespace) -> bool:
- from flocks.updater import updater
+ backup_path = Path(args.backup_path)
try:
- updater._prepare_upgrade_handover(args.version)
+ _apply_new_source(args)
except Exception as exc:
- _record_handoff_log(f"prepare_handover_failed error={exc}")
- return False
- return True
-
-
-def _rollback_upgrade_handover() -> None:
- from flocks.updater import updater
+ error = str(exc)
+ _record_handoff_log(f"source_replace_failed error={error}")
+ _write_upgrade_result(
+ args=args,
+ phase="failed",
+ failed_stage="source_replace",
+ error=error,
+ backup_path=backup_path,
+ )
+ return 1
try:
- updater.rollback_upgrade_handover()
+ task_error = _run_upgrade_tasks(args)
except Exception as exc:
- _record_handoff_log(f"handover_rollback_failed error={exc}")
+ task_error = f"upgrade tasks crashed: {exc}"
+ if task_error is not None:
+ _record_handoff_log(f"install_failed error={task_error}")
+ _write_upgrade_result(
+ args=args,
+ phase="failed",
+ failed_stage="install",
+ error=task_error,
+ backup_path=backup_path,
+ stderr=task_error,
+ )
+ return 1
+
+ _report_pending_pro_bundle_install_receipt(args)
+ started, stdout, stderr = _start_service_after_upgrade(args)
+ if not started:
+ error = "service restart failed"
+ _write_upgrade_result(
+ args=args,
+ phase="failed",
+ failed_stage="start",
+ error=error,
+ backup_path=backup_path,
+ stdout=stdout,
+ stderr=stderr,
+ )
+ return 1
+
+ _write_upgrade_result(args=args, phase="done", backup_path=backup_path)
+ _cleanup_dir(args.cleanup_dir)
+ return 0
def _cleanup_dir(path_value: str | None) -> None:
@@ -202,6 +428,86 @@ def _cleanup_dir(path_value: str | None) -> None:
shutil.rmtree(Path(path_value), ignore_errors=True)
+def _legacy_upgrade_page_pids(args: argparse.Namespace, page_dir: Path, pid_path: Path) -> list[int]:
+ """Find trusted temporary upgrade-page processes from older handoffs."""
+ candidates: set[int] = set()
+ try:
+ candidates.update(service_manager.port_owner_pids(args.frontend_port))
+ except Exception:
+ pass
+ try:
+ candidates.add(int(pid_path.read_text(encoding="utf-8").strip()))
+ except (OSError, ValueError):
+ pass
+
+ page_dir_text = str(page_dir).lower()
+ matches: list[int] = []
+ for pid in sorted(candidates):
+ try:
+ command_line = service_manager._process_command_line(pid).lower()
+ except Exception:
+ continue
+ if "http.server" in command_line and "upgrade-page" in command_line and page_dir_text in command_line:
+ matches.append(pid)
+ return matches
+
+
+def _cleanup_legacy_upgrade_handover(args: argparse.Namespace) -> bool:
+ """Stop and remove upgrade-page artifacts left by old handoff protocols."""
+ run_dir = updater_module._flocks_root() / "run"
+ state_path = run_dir / "upgrade-state.json"
+ pid_path = run_dir / "upgrade_server.pid"
+ page_dir = run_dir / "upgrade-page"
+ if not state_path.exists() and not pid_path.exists() and not page_dir.exists():
+ return True
+ page_pids = _legacy_upgrade_page_pids(args, page_dir, pid_path)
+
+ try:
+ for pid in page_pids:
+ service_manager._terminate_orphan_pid(pid, "升级临时页", _NullConsole())
+ except Exception as exc:
+ _record_handoff_log(f"legacy_handover_stop_failed error={exc}")
+ return False
+
+ remaining = _legacy_upgrade_page_pids(args, page_dir, pid_path)
+ if remaining:
+ _record_handoff_log(f"legacy_handover_still_running pids={remaining}")
+ return False
+
+ state_path.unlink(missing_ok=True)
+ pid_path.unlink(missing_ok=True)
+ shutil.rmtree(page_dir, ignore_errors=True)
+ if page_pids:
+ _record_handoff_log(f"legacy_handover_cleaned pids={page_pids}")
+ return True
+
+
+def _legacy_supervisor_pid(args: argparse.Namespace) -> int | None:
+ """Capture the daemon PID associated with an older handoff request."""
+ try:
+ candidates = service_manager.trusted_daemon_process_pids(root=Path(args.install_root))
+ except Exception as exc:
+ _record_handoff_log(f"legacy_daemon_scan_failed error={exc}")
+ return None
+
+ port_tokens = (f"--server-port {args.backend_port}", f"--server-port={args.backend_port}")
+ matches: list[int] = []
+ for pid in candidates:
+ try:
+ command_line = service_manager._process_command_line(pid).lower()
+ except Exception:
+ continue
+ if any(token in command_line for token in port_tokens):
+ matches.append(pid)
+ if len(matches) == 1:
+ return matches[0]
+ if not matches and len(candidates) == 1:
+ return candidates[0]
+ if candidates:
+ _record_handoff_log(f"legacy_daemon_scan_ambiguous pids={candidates}")
+ return None
+
+
def _cli_subcommand(argv: Sequence[str]) -> str | None:
"""Return the flocks.cli.main subcommand embedded in a Python argv."""
for index, value in enumerate(argv[:-2]):
@@ -210,6 +516,40 @@ def _cli_subcommand(argv: Sequence[str]) -> str | None:
return None
+def _restore_legacy_handoff_endpoints(args: argparse.Namespace) -> None:
+ """Restore endpoints lost by pre-v2026.7.8 split-service handoffs."""
+ if not args.backend_pid_file or _cli_subcommand(args.restart_argv) != "serve":
+ return
+
+ state_path = updater_module._flocks_root() / "run" / "upgrade-state.json"
+ try:
+ payload = json.loads(state_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ return
+ except (OSError, json.JSONDecodeError) as exc:
+ _record_handoff_log(f"legacy_service_config_invalid error={exc}")
+ return
+
+ if not isinstance(payload, dict):
+ _record_handoff_log("legacy_service_config_invalid error=state is not an object")
+ return
+
+ hosts = (payload.get("backend_host"), payload.get("frontend_host"))
+ ports = (payload.get("backend_port"), payload.get("frontend_port"))
+ if not all(isinstance(host, str) and host.strip() for host in hosts) or not all(
+ isinstance(port, int) and not isinstance(port, bool) and 0 < port <= 65535 for port in ports
+ ):
+ _record_handoff_log("legacy_service_config_invalid error=invalid host or port")
+ return
+
+ args.backend_host, args.frontend_host = hosts
+ args.backend_port, args.frontend_port = ports
+ _record_handoff_log(
+ "legacy_service_config_restored "
+ f"backend={args.backend_host}:{args.backend_port} frontend={args.frontend_host}:{args.frontend_port}"
+ )
+
+
def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Sequence[str]) -> list[str]:
if _cli_subcommand(restart_argv) != "serve":
return list(restart_argv)
@@ -236,6 +576,10 @@ def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Se
def run(argv: Sequence[str] | None = None) -> int:
args = _parse_args(argv)
+ if args.mode == "upgrade":
+ return _run_simple_upgrade(args)
+
+ _restore_legacy_handoff_endpoints(args)
restart_argv = _restart_argv_for_current_runtime(args, args.restart_argv)
if not restart_argv:
_record_handoff_log("missing_restart_argv")
@@ -246,14 +590,36 @@ def run(argv: Sequence[str] | None = None) -> int:
f"parent_pid={args.parent_pid} backend={args.backend_host}:{args.backend_port} "
f"frontend={args.frontend_host}:{args.frontend_port}"
)
+ legacy_daemon_pid = _legacy_supervisor_pid(args) if args.prepare_handover else None
- if not _wait_for_parent_exit(args.parent_pid):
+ if args.parent_pid is not None and not _wait_for_parent_exit(args.parent_pid):
_record_handoff_log(f"parent_exit_timeout parent_pid={args.parent_pid}")
_cleanup_dir(args.cleanup_dir)
return 1
+ supervisor_stopped = False
if args.prepare_handover:
- if not _prepare_upgrade_handover(args):
+ supervisor_stopped = _stop_supervisor_before_restart(
+ daemon_pid=legacy_daemon_pid,
+ backend_port=args.backend_port,
+ service_ports=(args.frontend_port,),
+ force_daemon_stop=True,
+ )
+ if not supervisor_stopped:
+ _record_handoff_log("legacy_handover_stop_timeout")
+ _cleanup_dir(args.cleanup_dir)
+ return 1
+ elif args.pro_wheel_path or args.pro_bundle_manifest_path:
+ supervisor_stopped = _stop_supervisor_before_restart(
+ backend_port=args.backend_port,
+ service_ports=(args.frontend_port,),
+ )
+ if not supervisor_stopped:
+ _record_handoff_log("pro_handover_stop_timeout")
+ _cleanup_dir(args.cleanup_dir)
+ return 1
+ if not _ensure_backend_port_free(args.backend_port):
+ _record_handoff_log(f"backend_port_unavailable port={args.backend_port}")
_cleanup_dir(args.cleanup_dir)
return 1
elif not _ensure_backend_port_free(args.backend_port):
@@ -266,15 +632,19 @@ def run(argv: Sequence[str] | None = None) -> int:
except Exception as exc:
task_error = f"upgrade tasks crashed: {exc}"
if task_error is not None:
- _rollback_failed_upgrade(args, task_error)
+ _record_handoff_log(f"upgrade_tasks_failed error={task_error}")
_cleanup_dir(args.cleanup_dir)
return 1
_report_pending_pro_bundle_install_receipt(args)
- if not _stop_supervisor_before_restart():
+ uses_legacy_protocol = bool(args.backup_path or args.prepare_handover or args.backend_pid_file)
+ if uses_legacy_protocol and not _cleanup_legacy_upgrade_handover(args):
+ _record_handoff_log("legacy_handover_cleanup_failed")
+ _cleanup_dir(args.cleanup_dir)
+ return 1
+
+ if not supervisor_stopped and not _stop_supervisor_before_restart():
_record_handoff_log("supervisor_stop_timeout")
- if args.prepare_handover:
- _rollback_upgrade_handover()
_cleanup_dir(args.cleanup_dir)
return 1
@@ -286,8 +656,6 @@ def run(argv: Sequence[str] | None = None) -> int:
)
except OSError as exc:
_record_handoff_log(f"restart_spawn_failed error={exc}")
- if args.prepare_handover:
- _rollback_upgrade_handover()
_cleanup_dir(args.cleanup_dir)
return 1
diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py
index 3ebdf7791..bfc418415 100644
--- a/flocks/updater/updater.py
+++ b/flocks/updater/updater.py
@@ -1,10 +1,8 @@
-"""
-Core updater logic
+"""Core updater logic.
-Checks for updates via GitHub / Gitee / GitLab Releases API, downloads
-the source archive (zip / tar.gz), backs up the current installation,
-extracts the new source over it, re-syncs dependencies, and restarts the
-process in-place.
+The active process downloads and validates source archives. A detached
+handoff stops the old service, backs up and replaces the source tree, repairs
+the installation, and restores the captured service state.
No git binary is required at runtime — all code fetching is done via HTTP.
@@ -13,13 +11,11 @@
"""
import asyncio
-import contextlib
import importlib.util
import json
import os
import re
import shutil
-import signal
import subprocess
import sys
import tarfile
@@ -40,13 +36,6 @@
_DEFAULT_REPO = "AgentFlocks/Flocks"
_BACKUP_DIR = Path.home() / ".flocks" / "version"
-_UPGRADE_PAGE_MARKER = "flocks-upgrade-in-progress"
-_UPGRADE_PHASE_HANDOVER_PREPARING = "handover_preparing"
-_UPGRADE_PHASE_TEMP_PAGE_ACTIVE = "temporary_page_active"
-_UPGRADE_PHASE_CUTOVER_APPLIED = "cutover_applied"
-_UPGRADE_PHASE_ROLLBACK_IN_PROGRESS = "rollback_in_progress"
-_UPGRADE_PHASE_ROLLBACK_FAILED = "rollback_failed"
-_STATE_FIELD_UNSET = object()
_UPDATE_REGION_CN = "cn"
_CN_NPM_REGISTRY = "https://registry.npmmirror.com/"
_CN_UV_DEFAULT_INDEX = "https://mirrors.aliyun.com/pypi/simple"
@@ -54,9 +43,8 @@
_CURL_USER_AGENT = "curl/8.7.1"
_FRONTEND_DEPENDENCY_INSTALL_TIMEOUT_SECONDS = 300
_FRONTEND_BUILD_TIMEOUT_SECONDS = 300
-_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 180
+_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300
_WINDOWS_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300
-_CANCELLATION_RETRY_DELAY_SECONDS = 0.1
_PRESERVE_NAMES: set[str] = {
".venv",
@@ -67,6 +55,17 @@
"__pycache__",
}
+_ROOT_RUNTIME_NAMES: set[str] = {
+ ".secret.json",
+ "config.json",
+ "data",
+ "flocks.json",
+ "flocks.jsonc",
+ "mcp_list.json",
+ "run",
+ "storage",
+}
+
log = Log.create(service="updater")
@@ -109,6 +108,15 @@ class UpdateMirrorProfile:
pip_index_url: str | None = None
+@dataclass(frozen=True)
+class ServiceSnapshot:
+ """Service state captured once before the detached upgrade starts."""
+
+ config: Any
+ daemon_pid: int | None
+ was_running: bool
+
+
@dataclass(frozen=True)
class _FrontendNpmCandidate:
"""A single npm launcher candidate for frontend rebuilds."""
@@ -118,12 +126,6 @@ class _FrontendNpmCandidate:
source: str
-@dataclass(frozen=True)
-class _ProComponentSnapshot:
- installed: bool
- version: str | None = None
-
-
# ------------------------------------------------------------------ #
# Install root
# ------------------------------------------------------------------ #
@@ -517,18 +519,6 @@ async def build_updated_frontend(
raise RuntimeError(frontend_error)
-async def _await_ignoring_cancellation(awaitable):
- """Finish a post-handover critical step even if the SSE client disconnects."""
- task = asyncio.create_task(awaitable)
- while True:
- try:
- return await asyncio.shield(task)
- except asyncio.CancelledError:
- log.warning("updater.restart.critical_step_cancelled_ignored")
- with contextlib.suppress(asyncio.CancelledError):
- await asyncio.sleep(_CANCELLATION_RETRY_DELAY_SECONDS)
-
-
def _dependency_sync_timeout_seconds() -> int:
"""Return the timeout budget for ``uv sync`` during self-update."""
if sys.platform == "win32":
@@ -538,7 +528,7 @@ def _dependency_sync_timeout_seconds() -> int:
def _build_dependency_sync_command(uv_path: str, *, uv_default_index: str | None = None) -> list[str]:
"""Build the ``uv sync`` command used by the self-updater."""
- cmd = [uv_path, "sync", "--frozen", "--no-python-downloads"]
+ cmd = [uv_path, "sync", "--no-python-downloads"]
if uv_default_index:
cmd.extend(["--default-index", uv_default_index])
return cmd
@@ -569,10 +559,31 @@ async def _run_uv_sync(cmd: list[str]) -> tuple[int, str, str]:
def _timeout_message() -> str:
return f"Dependency sync timed out after {effective_timeout}s while running uv sync."
+ def _log_timeout(exc: subprocess.TimeoutExpired, *, retry_without_default_index: bool) -> None:
+ log.warning(
+ "updater.dependencies.sync_timeout",
+ {
+ "command": list(exc.cmd) if not isinstance(exc.cmd, str) else exc.cmd,
+ "timeout": effective_timeout,
+ "stdout": _clean_process_output(exc.stdout),
+ "stderr": _clean_process_output(exc.stderr),
+ "retry_without_default_index": retry_without_default_index,
+ },
+ )
+
try:
code, _, err = await _run_uv_sync(uv_cmd)
- except subprocess.TimeoutExpired:
- return _timeout_message()
+ except subprocess.TimeoutExpired as exc:
+ _log_timeout(exc, retry_without_default_index=bool(uv_default_index))
+ if not uv_default_index:
+ return _timeout_message()
+ uv_cmd = _build_dependency_sync_command(uv_path)
+ uv_default_index = None
+ try:
+ code, _, err = await _run_uv_sync(uv_cmd)
+ except subprocess.TimeoutExpired as fallback_exc:
+ _log_timeout(fallback_exc, retry_without_default_index=False)
+ return _timeout_message()
if (
code != 0
@@ -595,7 +606,8 @@ def _timeout_message() -> str:
await asyncio.sleep(2)
try:
code, _, err = await _run_uv_sync(uv_cmd)
- except subprocess.TimeoutExpired:
+ except subprocess.TimeoutExpired as exc:
+ _log_timeout(exc, retry_without_default_index=False)
return _timeout_message()
if code != 0 and uv_default_index:
@@ -610,7 +622,8 @@ def _timeout_message() -> str:
uv_cmd = _build_dependency_sync_command(uv_path)
try:
code, _, err = await _run_uv_sync(uv_cmd)
- except subprocess.TimeoutExpired:
+ except subprocess.TimeoutExpired as exc:
+ _log_timeout(exc, retry_without_default_index=False)
return _timeout_message()
if code != 0:
@@ -618,7 +631,8 @@ def _timeout_message() -> str:
await asyncio.sleep(3)
try:
code, _, err = await _run_uv_sync(uv_cmd)
- except subprocess.TimeoutExpired:
+ except subprocess.TimeoutExpired as exc:
+ _log_timeout(exc, retry_without_default_index=False)
return _timeout_message()
if code != 0:
@@ -684,197 +698,17 @@ def _upgrade_run_dir() -> Path:
return _flocks_root() / "run"
-def _upgrade_log_dir() -> Path:
- return _flocks_root() / "logs"
-
-
-def _upgrade_state_path() -> Path:
- return _upgrade_run_dir() / "upgrade-state.json"
+def _upgrade_result_path() -> Path:
+ return _upgrade_run_dir() / "upgrade-result.json"
-def _upgrade_server_pid_path() -> Path:
- return _upgrade_run_dir() / "upgrade_server.pid"
-
-
-def _upgrade_page_dir() -> Path:
- return _upgrade_run_dir() / "upgrade-page"
-
-
-def _upgrade_page_log_path() -> Path:
- return _upgrade_log_dir() / "upgrade-page.log"
-
-
-def _read_upgrade_state() -> dict[str, Any] | None:
- path = _upgrade_state_path()
- if not path.exists():
- return None
- try:
- payload = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- return None
- return payload if isinstance(payload, dict) else None
-
-
-def _write_upgrade_state(payload: dict[str, Any]) -> None:
- path = _upgrade_state_path()
+def _write_upgrade_result_state(payload: dict[str, Any]) -> None:
+ """Persist diagnostics from the detached upgrade helper."""
+ path = _upgrade_result_path()
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8")
-
-
-def _clear_upgrade_state() -> None:
- _upgrade_state_path().unlink(missing_ok=True)
-
-
-def _persist_upgrade_state(
- payload: dict[str, Any],
- *,
- phase: str | None = None,
- last_error: str | None | object = _STATE_FIELD_UNSET,
-) -> dict[str, Any]:
- if phase is not None:
- payload["phase"] = phase
- if last_error is not _STATE_FIELD_UNSET:
- if last_error:
- payload["last_error"] = last_error
- else:
- payload.pop("last_error", None)
- _write_upgrade_state(payload)
- return payload
-
-
-def _upgrade_page_html(version: str) -> str:
- return f"""
-
-
-
-
- Flocks 升级中
-
-
-
-
- Flocks 正在升级
- 系统升级中,请稍候
- 升级期间服务会短暂重启。当前页面会在新版本恢复后自动刷新。
- v{version}
-
- 如果页面长时间没有恢复,请稍后手动刷新一次。
-
-
-
-
-"""
-
-
-# ------------------------------------------------------------------ #
-# Config helper
-# ------------------------------------------------------------------ #
+ result = dict(payload)
+ result["updated_at"] = datetime.now(timezone.utc).isoformat()
+ path.write_text(json.dumps(result, ensure_ascii=True, sort_keys=True), encoding="utf-8")
async def _get_updater_config():
@@ -1622,7 +1456,7 @@ def _backup_current_version(
retain_count: int = 1,
) -> Path | None:
"""
- Compress the current install directory into ~/.flocks/version/ .
+ Compress the current source tree into ~/.flocks/version/ .
Returns the backup path on success, None on failure.
Preserved runtime/user directories are excluded.
"""
@@ -1633,7 +1467,9 @@ def _backup_current_version(
def _filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
parts = info.name.split("/")
- for index, part in enumerate(parts):
+ if len(parts) >= 2 and parts[0] == "flocks" and parts[1] in _ROOT_RUNTIME_NAMES:
+ return None
+ for part in parts:
if part in _PRESERVE_NAMES:
return None
if part == "dist":
@@ -1737,62 +1573,34 @@ def _venv_python_path(install_root: Path) -> Path:
return install_root / ".venv" / "bin" / "python"
-async def _snapshot_pro_component(install_root: Path) -> _ProComponentSnapshot:
- python_path = _venv_python_path(install_root)
- if not python_path.exists():
- return _ProComponentSnapshot(installed=False)
- code, out, _ = await _run_async(
- [
- str(python_path),
- "-c",
- "import importlib.metadata as m\n"
- "import sys\n"
- "try:\n"
- " print(m.version('flockspro'))\n"
- "except m.PackageNotFoundError:\n"
- " sys.exit(2)\n",
- ],
- cwd=install_root,
- timeout=30,
- )
- if code == 0 and out.strip():
- return _ProComponentSnapshot(installed=True, version=out.strip())
- return _ProComponentSnapshot(installed=False)
-
-
-async def _restore_pro_component_snapshot(
- snapshot: _ProComponentSnapshot,
- *,
- uv_path: str,
+async def install_or_repair_source(
install_root: Path,
- env: dict[str, str] | None,
-) -> str | None:
- python_path = _venv_python_path(install_root)
- if not python_path.exists():
- return f"Python environment may need manual repair: missing {python_path}"
- if snapshot.installed and snapshot.version:
- cmd = [uv_path, "pip", "install", "--python", str(python_path), "--no-deps", f"flockspro=={snapshot.version}"]
- else:
- cmd = [uv_path, "pip", "uninstall", "--python", str(python_path), "-y", "flockspro"]
- code, _, err = await _run_async(cmd, cwd=install_root, timeout=180, env=env)
- if code != 0:
- return f"Python environment may need manual repair: {err}"
- return None
-
-
-async def run_handoff_upgrade_tasks(
*,
- install_root: Path,
- uv_path: str,
version: str,
+ uv_path: str,
uv_default_index: str | None = None,
npm_registry: str | None = None,
+ sync_timeout: int = 300,
pro_wheel_path: Path | None = None,
pro_bundle_manifest_path: Path | None = None,
bundle_sha256: str | None = None,
- sync_timeout: int | None = None,
-) -> str | None:
- """Run upgrade work that must happen after the old service exits."""
+) -> None:
+ """Install or repair the active source tree after managed services stop."""
+ install_webui_dir = install_root / "webui"
+ if install_webui_dir.is_dir() and (install_webui_dir / "package.json").exists():
+ from flocks.cli import service_manager
+
+ install_script = "scripts/install.ps1" if sys.platform == "win32" else "scripts/install.sh"
+ if service_manager.resolve_npm_executable() is None:
+ raise RuntimeError(
+ f"npm was not found. Run {install_script} to install Node.js and npm."
+ )
+ if not service_manager.node_version_satisfies_requirement():
+ raise RuntimeError(
+ f"Node.js {service_manager.MIN_NODE_MAJOR}+ is required. "
+ f"Run {install_script} to install a compatible runtime."
+ )
+
sync_env = _build_uv_sync_env()
sync_error = await _sync_project_dependencies(
uv_path=uv_path,
@@ -1802,7 +1610,7 @@ async def run_handoff_upgrade_tasks(
env=sync_env,
)
if sync_error is not None:
- return sync_error
+ raise RuntimeError(sync_error)
if pro_wheel_path is not None:
python_path = _venv_python_path(install_root)
@@ -1814,34 +1622,28 @@ async def run_handoff_upgrade_tasks(
env=sync_env,
)
if code != 0:
- return f"Flocks Pro component install failed: {err}"
+ raise RuntimeError(f"Flocks Pro component install failed: {err}")
- validation_error = await _validate_restart_runtime(install_root)
- if validation_error:
- return validation_error
-
- install_webui_dir = install_root / "webui"
if install_webui_dir.is_dir() and (install_webui_dir / "package.json").exists():
frontend_error = await _build_frontend_workspace(
install_webui_dir,
npm_registry=npm_registry,
)
if frontend_error is not None:
- return frontend_error
+ raise RuntimeError(frontend_error)
+
+ validation_error = await _validate_restart_runtime(install_root)
+ if validation_error:
+ raise RuntimeError(validation_error)
+
+ _refresh_global_cli_entry(install_root)
- _write_version_marker(version.lstrip("v"))
if pro_bundle_manifest_path is not None and pro_bundle_manifest_path.is_file():
_write_pro_bundle_install_marker(
_load_json_file(pro_bundle_manifest_path),
bundle_sha256=bundle_sha256,
)
-
- try:
- _refresh_global_cli_entry(install_root)
- except Exception as exc:
- log.warning("updater.refresh_cli.failed", {"error": str(exc)})
-
- return None
+ _write_version_marker(version.lstrip("v"))
def _merge_console_manifest_release_identity(
@@ -1971,11 +1773,6 @@ async def _uninstall_pro_component(
return None
-class _NullConsole:
- def print(self, *args, **kwargs) -> None:
- return None
-
-
def _current_service_config():
from flocks.cli import service_manager
from flocks.cli.service_config import service_config_from_status_payload
@@ -1992,6 +1789,68 @@ def _current_service_config():
)
+def _capture_service_snapshot() -> ServiceSnapshot:
+ """Capture restart configuration and liveness before the service is stopped."""
+ from flocks.cli import service_manager
+ from flocks.cli.service_config import ServiceConfig, service_config_from_status_payload
+ from flocks.cli.service_control import read_supervisor_status
+
+ paths = service_manager.runtime_paths()
+ try:
+ status = read_supervisor_status(paths=paths, timeout=1.0)
+ except Exception as exc:
+ backend_record = service_manager.read_runtime_record(paths.backend_pid)
+ frontend_record = service_manager.read_runtime_record(paths.frontend_pid)
+ backend_running = service_manager.runtime_record_is_running(backend_record)
+ frontend_running = service_manager.runtime_record_is_running(frontend_record)
+ if backend_running or frontend_running:
+ backend_host = backend_record.host if backend_record and backend_record.host else "127.0.0.1"
+ backend_port = backend_record.port if backend_record and backend_record.port else 5173
+ frontend_host = frontend_record.host if frontend_record and frontend_record.host else backend_host
+ frontend_port = frontend_record.port if frontend_record and frontend_record.port else backend_port
+ config = ServiceConfig(
+ backend_host=backend_host,
+ backend_port=backend_port,
+ frontend_host=frontend_host,
+ frontend_port=frontend_port,
+ no_browser=True,
+ skip_frontend_build=True,
+ )
+ return ServiceSnapshot(config=config, daemon_pid=None, was_running=True)
+
+ try:
+ daemon_pids = service_manager.trusted_daemon_process_pids(root=_get_repo_root())
+ except Exception:
+ daemon_pids = []
+ if daemon_pids:
+ raise RuntimeError(
+ "Supervisor is running but its control API is unavailable; "
+ "cannot safely capture the service configuration."
+ ) from exc
+ return ServiceSnapshot(
+ config=ServiceConfig(no_browser=True, skip_frontend_build=True),
+ daemon_pid=None,
+ was_running=False,
+ )
+
+ config = service_config_from_status_payload(
+ status.raw,
+ )
+ backend_state = status.backend.state.lower()
+ was_running = bool(status.backend.pid) or backend_state in {
+ "healthy",
+ "starting",
+ "restarting",
+ "paused",
+ "static",
+ }
+ return ServiceSnapshot(
+ config=config,
+ daemon_pid=status.daemon.pid,
+ was_running=was_running,
+ )
+
+
def _spawn_detached_process(
command: list[str],
*,
@@ -2027,491 +1886,19 @@ def _spawn_detached_process(
handle.close()
-def _write_upgrade_page(version: str) -> Path:
- page_dir = _upgrade_page_dir()
- page_dir.mkdir(parents=True, exist_ok=True)
- (page_dir / "index.html").write_text(_upgrade_page_html(version), encoding="utf-8")
- return page_dir
-
-
-def _upgrade_page_probe_urls(frontend_host: str, frontend_port: int) -> list[str]:
- from flocks.cli import service_manager
-
- if frontend_host == "::":
- return [
- f"http://[::1]:{frontend_port}",
- f"http://127.0.0.1:{frontend_port}",
- ]
- return [f"http://{service_manager.access_host(frontend_host)}:{frontend_port}"]
-
-
-def _wait_for_upgrade_page(config) -> None:
- page_urls = _upgrade_page_probe_urls(config.frontend_host, config.frontend_port)
- with httpx.Client(timeout=1.5) as client:
- for _ in range(40):
- for page_url in page_urls:
- try:
- response = client.get(page_url)
- if response.status_code < 500:
- return
- except Exception:
- pass
- time.sleep(0.25)
- raise RuntimeError("Upgrade page server failed to start in time")
-
-
-def _start_upgrade_page_server(config, version: str) -> dict[str, Any]:
- from flocks.cli import service_manager
-
- page_dir = _write_upgrade_page(version)
- page_dir_resolved = page_dir.resolve()
- process = _spawn_detached_process(
- service_manager.resolve_python_subprocess_command(_get_repo_root())
- + [
- "-m",
- "http.server",
- str(config.frontend_port),
- "--bind",
- config.frontend_host,
- "--directory",
- str(page_dir_resolved),
- ],
- cwd=page_dir_resolved,
- log_path=_upgrade_page_log_path(),
- )
- _upgrade_server_pid_path().write_text(str(process.pid), encoding="utf-8")
- _wait_for_upgrade_page(config)
- return {
- "page_dir": str(page_dir_resolved),
- "page_log": str(_upgrade_page_log_path().resolve()),
- "upgrade_server_pid": process.pid,
- }
-
-
-def _stop_upgrade_page_server(*, frontend_port: int | None = None) -> None:
- pid_path = _upgrade_server_pid_path()
- if pid_path.exists():
- try:
- pid = int(pid_path.read_text(encoding="utf-8").strip())
- except (OSError, ValueError):
- pid = None
- pid_path.unlink(missing_ok=True)
-
- if pid is not None:
- try:
- if sys.platform == "win32":
- subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True)
- else:
- os.kill(pid, signal.SIGTERM)
- except OSError:
- pass
-
- for _ in range(40):
- try:
- os.kill(pid, 0)
- except OSError:
- break
- time.sleep(0.1)
- else:
- if sys.platform != "win32":
- try:
- os.kill(pid, signal.SIGKILL)
- except OSError:
- pass
- time.sleep(0.1)
-
- pid_path.unlink(missing_ok=True)
-
- if frontend_port is None:
- return
-
- from flocks.cli import service_manager
-
- remaining = [
- pid
- for pid in service_manager.port_owner_pids(frontend_port)
- if _looks_like_upgrade_page_process(pid)
- ]
- if remaining:
- log.info(
- "updater.upgrade_page.port_fallback_kill",
- {
- "port": frontend_port,
- "pids": remaining,
- },
- )
- for rpid in remaining:
- try:
- if sys.platform == "win32":
- subprocess.run(["taskkill", "/PID", str(rpid), "/T", "/F"], check=False, capture_output=True)
- else:
- os.kill(rpid, signal.SIGKILL)
- except OSError:
- pass
-
- if sys.platform == "win32":
- wait_attempts = 40
- wait_interval = 0.25
- for _ in range(wait_attempts):
- if not any(_looks_like_upgrade_page_process(pid) for pid in service_manager.port_owner_pids(frontend_port)):
- return
- time.sleep(wait_interval)
- return
-
- if remaining:
- time.sleep(0.3)
-
-
-def _looks_like_upgrade_page_process(pid: int) -> bool:
- """Return True only for the temporary upgrade-page http.server process."""
- try:
- from flocks.cli import service_manager
-
- command_line = service_manager._process_command_line(pid).lower()
- except Exception:
- return False
- if not command_line:
- return False
- page_dir = str(_upgrade_page_dir()).lower()
- return "http.server" in command_line and "upgrade-page" in command_line and page_dir in command_line
-
-
-def _prepare_upgrade_handover(version: str) -> dict[str, Any]:
- from flocks.cli import service_manager
- from flocks.cli.service_control import request_prepare_upgrade
-
- config = _current_service_config()
- payload: dict[str, Any] = {
- "version": version,
- "backend_host": config.backend_host,
- "backend_port": config.backend_port,
- "frontend_host": config.frontend_host,
- "frontend_port": config.frontend_port,
- "skip_frontend_build": True,
- "phase": _UPGRADE_PHASE_HANDOVER_PREPARING,
- }
- _persist_upgrade_state(payload, last_error=None)
-
- console = _NullConsole()
- paths = service_manager.runtime_paths()
- request_prepare_upgrade(paths=paths, timeout=30.0)
-
- try:
- payload.update(_start_upgrade_page_server(config, version))
- _persist_upgrade_state(
- payload,
- phase=_UPGRADE_PHASE_TEMP_PAGE_ACTIVE,
- last_error=None,
- )
- except Exception:
- _stop_upgrade_page_server(frontend_port=config.frontend_port)
- _clear_upgrade_state()
- try:
- _start_frontend_with_fallback(config, console, allow_build_fallback=False)
- except Exception as restart_error:
- log.error("updater.frontend.restore_failed", {"error": str(restart_error)})
- raise
-
- return payload
-
-
def _spawn_restart_handoff(command: list[str], *, cwd: Path) -> subprocess.Popen:
- creationflags = 0
- kwargs: dict[str, object] = {}
- if sys.platform == "win32":
- creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
- startupinfo_cls = getattr(subprocess, "STARTUPINFO", None)
- if startupinfo_cls is not None:
- startupinfo = startupinfo_cls()
- startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
- startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0)
- kwargs["startupinfo"] = startupinfo
- else:
- kwargs["start_new_session"] = True
- return subprocess.Popen(command, cwd=cwd, close_fds=True, creationflags=creationflags, **kwargs)
-
-
-def _service_config_from_payload(
- payload: dict[str, Any],
- *,
- skip_frontend_build: bool | None = None,
-):
- from flocks.cli.service_config import ServiceConfig, service_config_from_payload
-
- resolved_skip_frontend_build = (
- bool(payload.get("skip_frontend_build", True)) if skip_frontend_build is None else skip_frontend_build
- )
- migrated_payload = dict(payload)
- backend_port = migrated_payload.get("backend_port")
- frontend_port = migrated_payload.get("frontend_port")
- if isinstance(backend_port, int) and isinstance(frontend_port, int) and backend_port != frontend_port:
- migrated_payload["legacy_backend_host"] = migrated_payload.get("backend_host")
- migrated_payload["legacy_backend_port"] = backend_port
- migrated_payload["backend_host"] = migrated_payload.get("frontend_host") or migrated_payload.get("backend_host")
- migrated_payload["backend_port"] = frontend_port
- migrated_payload["server_port_migration_hint"] = True
- return service_config_from_payload(
- migrated_payload,
- default=ServiceConfig(),
- no_browser=True,
- skip_frontend_build=resolved_skip_frontend_build,
+ return _spawn_detached_process(
+ command,
+ cwd=cwd,
+ log_path=_flocks_root() / "logs" / "backend.log",
)
def _handoff_service_config():
- payload = _read_upgrade_state()
- if payload is not None:
- return _service_config_from_payload(payload, skip_frontend_build=True)
+ """Read the current config for restart-only handoffs."""
return _current_service_config()
-def _read_upgrade_server_pid() -> tuple[int | None, bool]:
- pid_path = _upgrade_server_pid_path()
- if not pid_path.exists():
- return None, False
- try:
- return int(pid_path.read_text(encoding="utf-8").strip()), True
- except (OSError, ValueError):
- pid_path.unlink(missing_ok=True)
- return None, True
-
-
-def _payload_frontend_port(payload: dict[str, Any] | None) -> int | None:
- if not payload:
- return None
- value = payload.get("frontend_port")
- if isinstance(value, bool):
- return None
- if isinstance(value, int):
- return value if value > 0 else None
- if isinstance(value, str) and value.isdigit():
- parsed = int(value)
- return parsed if parsed > 0 else None
- return None
-
-
-def read_upgrade_runtime_state(frontend_port: int | None = None) -> dict[str, Any]:
- payload = _read_upgrade_state()
- upgrade_pid, pid_file_present = _read_upgrade_server_pid()
- resolved_port = _payload_frontend_port(payload) or frontend_port
- frontend_host = str(payload.get("frontend_host")) if payload and payload.get("frontend_host") else None
-
- listener_pids: list[int] = []
- if resolved_port is not None:
- try:
- from flocks.cli import service_manager
-
- listener_pids = service_manager.port_owner_pids(resolved_port)
- except Exception:
- listener_pids = []
-
- listener_matches_pid = upgrade_pid is not None and upgrade_pid in listener_pids
- return {
- "payload": payload,
- "payload_present": payload is not None,
- "pid_file_present": pid_file_present,
- "upgrade_pid": upgrade_pid,
- "frontend_host": frontend_host,
- "frontend_port": resolved_port,
- "listener_pids": listener_pids,
- "listener_matches_pid": listener_matches_pid,
- "page_active": listener_matches_pid,
- "has_artifacts": payload is not None or pid_file_present,
- }
-
-
-def _webui_runtime_ready(state: str) -> bool:
- return state in {"healthy", "static"}
-
-
-def _start_frontend_with_fallback(config, console, *, allow_build_fallback: bool) -> None:
- from flocks.cli.service_config import with_frontend_build
- from flocks.cli.service_control import request_restart_webui, request_resume_upgrade
-
- try:
- status = request_resume_upgrade(
- config,
- paths=None,
- timeout=180.0,
- )
- if not _webui_runtime_ready(status.webui.state):
- raise RuntimeError(status.webui.last_error or "WebUI restart did not become healthy")
- return
- except Exception:
- if not allow_build_fallback or not config.skip_frontend_build:
- raise
-
- rebuilt_config = with_frontend_build(config, skip_frontend_build=False)
- result = request_restart_webui(rebuilt_config, force_frontend_build=True, paths=None, timeout=180.0)
- if not _webui_runtime_ready(result.webui.state):
- raise RuntimeError(result.webui.last_error or "WebUI restart did not become healthy")
-
-
-def cleanup_orphan_upgrade_state(*, frontend_port: int | None = None) -> bool:
- state = read_upgrade_runtime_state(frontend_port=frontend_port)
- if not state["has_artifacts"]:
- return False
-
- resolved_port = state["frontend_port"]
- if resolved_port is None:
- _stop_upgrade_page_server()
- else:
- _stop_upgrade_page_server(frontend_port=resolved_port)
-
- _clear_upgrade_state()
- _upgrade_server_pid_path().unlink(missing_ok=True)
- shutil.rmtree(_upgrade_page_dir(), ignore_errors=True)
- return True
-
-
-def resolve_upgrade_runtime_state(
- *,
- attempt_recover: bool = True,
- frontend_port: int | None = None,
-) -> dict[str, Any]:
- state = read_upgrade_runtime_state(frontend_port=frontend_port)
- if not state["has_artifacts"]:
- return {
- **state,
- "action": "noop",
- "error": None,
- }
-
- resolved_port = state["frontend_port"]
- if attempt_recover and state["payload_present"]:
- try:
- recover_upgrade_state()
- return {
- **state,
- "action": "recovered",
- "error": None,
- }
- except Exception as exc:
- cleanup_orphan_upgrade_state(frontend_port=resolved_port)
- return {
- **state,
- "action": "cleanup_after_failed_recover",
- "error": str(exc),
- }
-
- cleanup_orphan_upgrade_state(frontend_port=resolved_port)
- return {
- **state,
- "action": "cleaned",
- "error": None,
- }
-
-
-def _restore_backup_if_possible(
- backup_path: Path,
- install_root: Path,
- previous_version: str,
-) -> None:
- """Restore install tree from backup without touching upgrade-page state."""
- try:
- _restore_backup_archive(backup_path, install_root)
- _write_version_marker(previous_version)
- log.info("updater.backup.restored", {"backup": str(backup_path)})
- except Exception as exc:
- log.error(
- "updater.backup.restore_failed",
- {
- "backup": str(backup_path),
- "error": str(exc),
- },
- )
-
-
-def _restore_backup_archive(backup_path: Path, install_root: Path) -> None:
- restore_dir = Path(tempfile.mkdtemp(prefix="flocks-rollback-"))
- try:
- content_root = _extract_archive(backup_path, restore_dir)
- _replace_install_dir(content_root, install_root)
- finally:
- shutil.rmtree(restore_dir, ignore_errors=True)
-
-
-def _rollback_failed_update(
- backup_path: Path | None,
- install_root: Path,
- previous_version: str,
-) -> None:
- payload = _read_upgrade_state()
- if not payload:
- return
-
- _persist_upgrade_state(
- payload,
- phase=_UPGRADE_PHASE_ROLLBACK_IN_PROGRESS,
- last_error=None,
- )
- restored_backup = False
- restore_error: str | None = None
- if backup_path is not None:
- try:
- _restore_backup_archive(backup_path, install_root)
- _write_version_marker(previous_version.lstrip("v"))
- restored_backup = True
- except Exception as exc:
- restore_error = f"Failed to restore backup: {exc}"
- log.error("updater.backup.restore_failed", {"error": str(exc), "backup": str(backup_path)})
-
- console = _NullConsole()
- config = _service_config_from_payload(payload, skip_frontend_build=True)
- _stop_upgrade_page_server(frontend_port=config.frontend_port)
- try:
- _start_frontend_with_fallback(
- config,
- console,
- allow_build_fallback=restored_backup,
- )
- except Exception as exc:
- log.error(
- "updater.frontend.rollback_failed",
- {"error": str(exc), "restored_backup": restored_backup, "restore_error": restore_error},
- )
- finally:
- _clear_upgrade_state()
- shutil.rmtree(_upgrade_page_dir(), ignore_errors=True)
-
-
-def recover_upgrade_state() -> None:
- payload = _read_upgrade_state()
- if not payload:
- return
-
- console = _NullConsole()
- config = _service_config_from_payload(payload)
-
- _stop_upgrade_page_server(frontend_port=config.frontend_port)
- try:
- _start_frontend_with_fallback(config, console, allow_build_fallback=True)
- except Exception as exc:
- log.error("updater.frontend.resume_failed", {"error": str(exc)})
- raise
- finally:
- _clear_upgrade_state()
- shutil.rmtree(_upgrade_page_dir(), ignore_errors=True)
-
-
-def rollback_upgrade_handover() -> None:
- payload = _read_upgrade_state()
- if not payload:
- return
-
- console = _NullConsole()
- config = _service_config_from_payload(payload, skip_frontend_build=True)
-
- _stop_upgrade_page_server(frontend_port=config.frontend_port)
- try:
- _start_frontend_with_fallback(config, console, allow_build_fallback=False)
- except Exception as exc:
- log.error("updater.frontend.rollback_failed", {"error": str(exc)})
- finally:
- _clear_upgrade_state()
- shutil.rmtree(_upgrade_page_dir(), ignore_errors=True)
-
-
def cleanup_replaced_files(root: Path | None = None) -> None:
install_root = root or _get_repo_root()
leftovers = sorted(
@@ -2607,23 +1994,26 @@ def _safe_remove(target: Path) -> None:
def _replace_install_dir(
source_dir: Path,
install_root: Path,
+ *,
+ _is_root: bool = True,
) -> None:
"""
- Overwrite *install_root* with the contents of *source_dir*, while
- preserving user/runtime directories listed in ``_PRESERVE_NAMES``
- at **any** directory depth (not only the top level).
+ Overwrite *install_root* with the contents of *source_dir*. Build caches
+ are preserved at every depth; root-level configuration and runtime data
+ are preserved only at the installation root.
"""
install_root.mkdir(parents=True, exist_ok=True)
source_names = {item.name for item in source_dir.iterdir()}
+ preserve_names = _PRESERVE_NAMES | (_ROOT_RUNTIME_NAMES if _is_root else set())
for item in source_dir.iterdir():
- if item.name in _PRESERVE_NAMES:
+ if item.name in preserve_names:
continue
target = install_root / item.name
if item.is_dir() and not item.is_symlink():
if target.exists() or target.is_symlink():
if target.is_dir() and not target.is_symlink():
- _replace_install_dir(item, target)
+ _replace_install_dir(item, target, _is_root=False)
continue
_safe_remove(target)
shutil.copytree(item, target, symlinks=True)
@@ -2633,7 +2023,7 @@ def _replace_install_dir(
shutil.copy2(item, target)
for child in install_root.iterdir():
- if child.name not in source_names and child.name not in _PRESERVE_NAMES:
+ if child.name not in source_names and child.name not in preserve_names:
_safe_remove(child)
@@ -3108,11 +2498,7 @@ async def perform_pro_bundle_downgrade(
current_version=current_version,
)
log.info("updater.downgrade.restart_handoff_spawn", {"argv": handoff_argv})
- subprocess.Popen(
- handoff_argv,
- cwd=install_root,
- close_fds=True,
- )
+ _spawn_restart_handoff(handoff_argv, cwd=install_root)
os._exit(0)
except Exception as exc:
log.error("updater.downgrade.restart_failed", {"error": str(exc)})
@@ -3137,6 +2523,7 @@ async def perform_update(
locale: str | None = None,
region: str | None = None,
force_console_manifest: bool = False,
+ wait_for_handoff: bool = False,
) -> AsyncGenerator[UpdateProgress, None]:
"""
Async generator that executes the upgrade steps and yields progress events.
@@ -3268,27 +2655,8 @@ async def _queue_download_progress(progress: UpdateProgress) -> None:
return
# ------------------------------------------------------------------ #
- # Step 2 – backup current version
+ # Step 2 – validate and extract the staged source
# ------------------------------------------------------------------ #
- yield UpdateProgress(stage="backing_up", message="Backing up current version...")
-
- backup_path = await asyncio.to_thread(
- _backup_current_version,
- install_root,
- current_version,
- ucfg.backup_retain_count,
- )
- if backup_path:
- yield UpdateProgress(
- stage="backing_up",
- message=f"Backup complete: {backup_path.name}",
- )
- else:
- yield UpdateProgress(
- stage="backing_up",
- message="Backup skipped (non-fatal, continuing upgrade)",
- )
-
extract_dir = tmp_dir / "extracted"
extract_dir.mkdir()
try:
@@ -3309,8 +2677,6 @@ async def _queue_download_progress(progress: UpdateProgress) -> None:
except Exception as exc:
shutil.rmtree(tmp_dir, ignore_errors=True)
msg = f"Failed to extract files: {exc}"
- if backup_path:
- msg += f"\nRestore from backup: {backup_path}"
_record_update_journal(f"ERROR {msg}")
yield UpdateProgress(stage="error", message=msg, success=False)
return
@@ -3328,66 +2694,9 @@ async def _queue_download_progress(progress: UpdateProgress) -> None:
else:
effective_update_version = latest_tag
- # ------------------------------------------------------------------ #
- # Step 3 – determine whether frontend handover is needed
- # ------------------------------------------------------------------ #
- staged_webui_dir = content_root / "webui"
- needs_handover = (
- not skip_core_replace
- and staged_webui_dir.is_dir()
- and (staged_webui_dir / "package.json").exists()
- )
-
- # ------------------------------------------------------------------ #
- # Step 4 – replace install tree
- # (Frontend proxy is still alive so the SSE stream keeps flowing.)
- # ------------------------------------------------------------------ #
- yield UpdateProgress(
- stage="applying",
- message=(
- f"Keeping local Flocks {_version_label(current_version)} and installing the Pro component..."
- if skip_core_replace
- else f"Applying v{latest_tag}..."
- ),
- )
-
- async def _restore_after_apply_failure() -> None:
- if backup_path is None:
- return
- await asyncio.to_thread(
- _restore_backup_if_possible,
- backup_path,
- install_root,
- current_version,
- )
-
- try:
- if not skip_core_replace:
- await asyncio.to_thread(
- _replace_install_dir,
- content_root,
- install_root,
- )
- except Exception as exc:
- final_replace_error: Exception | None = exc
- if final_replace_error is not None:
- shutil.rmtree(tmp_dir, ignore_errors=True)
- await _restore_after_apply_failure()
- msg = f"Failed to replace files: {final_replace_error}"
- if backup_path:
- msg += f"\nRestore from backup: {backup_path}"
- yield UpdateProgress(stage="error", message=msg, success=False)
- return
-
- # ------------------------------------------------------------------ #
- # Step 5 – prepare dependency sync
- # ------------------------------------------------------------------ #
- yield UpdateProgress(stage="syncing", message="Preparing dependency sync...")
-
uv_path = _find_executable("uv")
if not uv_path:
shutil.rmtree(tmp_dir, ignore_errors=True)
- await _restore_after_apply_failure()
hint = (
"Dependency sync failed: uv is required but was not found. "
"Please install uv (https://docs.astral.sh/uv/) and ensure it "
@@ -3409,30 +2718,32 @@ async def _restore_after_apply_failure() -> None:
)
# ------------------------------------------------------------------ #
- # Step 6 – run post-apply tasks or restart via handoff
- #
- # With restart=True, dependency sync, Pro install, frontend build, marker
- # writes, and CLI refresh happen in restart_handoff after the old backend
- # exits. This avoids mutating the active Python environment while the old
- # service is still running.
+ # Step 3 – pure Pro installs may stay in process; source changes are backed
+ # up here and then always use the detached handoff.
# ------------------------------------------------------------------ #
- if not restart:
- task_error = await run_handoff_upgrade_tasks(
- install_root=install_root,
- uv_path=uv_path,
- version=effective_update_version,
- uv_default_index=profile.uv_default_index,
- npm_registry=profile.npm_registry,
- pro_wheel_path=pro_wheel_path,
- pro_bundle_manifest_path=pro_bundle_manifest_path,
- bundle_sha256=bundle_sha256,
- sync_timeout=sync_timeout,
+ if skip_core_replace:
+ yield UpdateProgress(
+ stage="applying",
+ message=f"Keeping local Flocks {_version_label(current_version)} and installing the Pro component...",
)
- if task_error is not None:
+
+ if skip_core_replace and not restart:
+ try:
+ await install_or_repair_source(
+ install_root=install_root,
+ uv_path=uv_path,
+ version=effective_update_version,
+ uv_default_index=profile.uv_default_index,
+ npm_registry=profile.npm_registry,
+ pro_wheel_path=pro_wheel_path,
+ pro_bundle_manifest_path=pro_bundle_manifest_path,
+ bundle_sha256=bundle_sha256,
+ sync_timeout=sync_timeout,
+ )
+ except RuntimeError as exc:
shutil.rmtree(tmp_dir, ignore_errors=True)
- await _restore_after_apply_failure()
- _record_update_journal(f"ERROR {task_error}")
- yield UpdateProgress(stage="error", message=task_error, success=False)
+ _record_update_journal(f"ERROR {exc}")
+ yield UpdateProgress(stage="error", message=str(exc), success=False)
return
shutil.rmtree(tmp_dir, ignore_errors=True)
@@ -3444,36 +2755,40 @@ async def _restore_after_apply_failure() -> None:
)
return
- yield UpdateProgress(stage="restarting", message="Restarting service...")
-
- log.info(
- "updater.restart",
- {
- "tag": latest_tag,
- "effective_version": effective_update_version,
- "sources": profile.sources,
- "repo": ucfg.repo,
- "region": profile.region,
- },
- )
- await asyncio.sleep(0.8)
+ if not restart:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ message = "Source upgrades require the detached handoff; restart=False is only valid for Pro-only changes."
+ _record_update_journal(f"ERROR {message}")
+ yield UpdateProgress(stage="error", message=message, success=False)
+ return
- if "--reload" in sys.argv:
- log.info("updater.restart.reload_exit3")
- sys.exit(3)
+ backup_path: Path | None = None
+ if not skip_core_replace:
+ yield UpdateProgress(stage="backing_up", message="Backing up current version...")
+ try:
+ backup_path = await asyncio.to_thread(
+ _backup_current_version,
+ install_root,
+ current_version,
+ ucfg.backup_retain_count,
+ )
+ except Exception as exc:
+ log.error("updater.backup.failed", {"error": str(exc)})
+ if backup_path is None:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ message = "Failed to back up the current source; the update was not applied."
+ _record_update_journal(f"ERROR {message}")
+ yield UpdateProgress(stage="error", message=message, success=False)
+ return
- try:
- restart_argv = _build_restart_argv(install_root)
- except Exception as exc:
- log.error("updater.restart.build_argv_failed", {"error": str(exc)})
yield UpdateProgress(
- stage="error",
- message=f"Failed to build restart command: {exc}",
- success=False,
+ stage="applying",
+ message=f"Staging v{latest_tag} for handoff...",
)
- return
try:
+ restart_argv = _build_restart_argv(install_root)
+ service_snapshot = _capture_service_snapshot() if not skip_core_replace else None
handoff_argv = _build_restart_handoff_argv(
restart_argv,
install_root,
@@ -3481,6 +2796,8 @@ async def _restore_after_apply_failure() -> None:
sync_timeout=sync_timeout,
version=effective_update_version,
current_version=current_version,
+ content_root=content_root if not skip_core_replace else None,
+ service_snapshot=service_snapshot,
backup_path=backup_path,
uv_default_index=profile.uv_default_index,
npm_registry=profile.npm_registry,
@@ -3488,8 +2805,37 @@ async def _restore_after_apply_failure() -> None:
pro_bundle_manifest_path=pro_bundle_manifest_path,
bundle_sha256=bundle_sha256,
cleanup_dir=tmp_dir,
- prepare_handover=needs_handover,
+ wait_for_parent=not wait_for_handoff,
+ )
+ except Exception as exc:
+ log.error("updater.restart.build_argv_failed", {"error": str(exc)})
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+ yield UpdateProgress(
+ stage="error",
+ message=f"Failed to prepare restart handoff: {exc}",
+ success=False,
)
+ return
+
+ yield UpdateProgress(stage="restarting", message="Restarting service...")
+
+ log.info(
+ "updater.restart",
+ {
+ "tag": latest_tag,
+ "effective_version": effective_update_version,
+ "sources": profile.sources,
+ "repo": ucfg.repo,
+ "region": profile.region,
+ },
+ )
+ await asyncio.sleep(0.8)
+
+ if "--reload" in sys.argv:
+ log.info("updater.restart.reload_exit3")
+ sys.exit(3)
+
+ try:
log.info(
"updater.restart.handoff_spawn",
{
@@ -3497,7 +2843,22 @@ async def _restore_after_apply_failure() -> None:
"restart_argv": restart_argv,
},
)
- _spawn_restart_handoff(handoff_argv, cwd=install_root)
+ handoff_process = _spawn_restart_handoff(handoff_argv, cwd=install_root)
+ if wait_for_handoff:
+ returncode = await asyncio.to_thread(handoff_process.wait)
+ if returncode != 0:
+ yield UpdateProgress(
+ stage="error",
+ message=f"Upgrade handoff failed with exit code {returncode}. See backend.log for details.",
+ success=False,
+ )
+ return
+ yield UpdateProgress(
+ stage="done",
+ message=f"Upgraded to {_version_label(effective_update_version)}",
+ success=True,
+ )
+ return
os._exit(0)
except Exception as exc:
log.error("updater.restart.handoff_spawn_failed", {"error": str(exc)})
@@ -3615,6 +2976,8 @@ def _build_restart_handoff_argv(
sync_timeout: int,
version: str,
current_version: str,
+ content_root: Path | None = None,
+ service_snapshot: ServiceSnapshot | None = None,
backup_path: Path | None = None,
uv_default_index: str | None = None,
npm_registry: str | None = None,
@@ -3622,56 +2985,91 @@ def _build_restart_handoff_argv(
pro_bundle_manifest_path: Path | None = None,
bundle_sha256: str | None = None,
cleanup_dir: Path | None = None,
- prepare_handover: bool = False,
+ wait_for_parent: bool = True,
) -> list[str]:
"""Wrap the real restart command in a helper that finishes upgrade work."""
if not restart_argv:
raise ValueError("restart command is empty")
- config = _handoff_service_config()
- managed_restart_argv = [
- restart_argv[0],
- "-m",
- "flocks.cli.main",
- "start",
- "--no-browser",
- "--skip-webui-build",
- "--host",
- str(config.backend_host),
- "--port",
- str(config.backend_port),
- ]
- if config.legacy_backend_host is not None:
- managed_restart_argv.extend(["--server-host", str(config.legacy_backend_host)])
- if config.legacy_backend_port is not None:
- managed_restart_argv.extend(["--server-port", str(config.legacy_backend_port)])
+ source_upgrade = content_root is not None
+ if source_upgrade:
+ if service_snapshot is None:
+ raise ValueError("source upgrade requires a captured service snapshot")
+ if backup_path is None:
+ raise ValueError("source upgrade requires a backup path")
+ config = service_snapshot.config
+ else:
+ config = _handoff_service_config()
+
+ from flocks.cli.service_config import service_config_payload
+
+ if source_upgrade:
+ # The upgrade helper reconstructs ``flocks start`` from the serialized
+ # snapshot after the old source has been replaced.
+ managed_restart_argv = [restart_argv[0]]
+ else:
+ managed_restart_argv = [
+ restart_argv[0],
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--no-browser",
+ "--skip-webui-build",
+ "--host",
+ str(config.frontend_host),
+ "--port",
+ str(config.frontend_port),
+ ]
+ if config.legacy_backend_host is not None:
+ managed_restart_argv.extend(["--server-host", str(config.legacy_backend_host)])
+ if config.legacy_backend_port is not None:
+ managed_restart_argv.extend(["--server-port", str(config.legacy_backend_port)])
argv = [
restart_argv[0],
"-m",
"flocks.updater.restart_handoff",
- "--parent-pid",
- str(os.getpid()),
- "--backend-host",
- str(config.backend_host),
- "--backend-port",
- str(config.backend_port),
- "--frontend-host",
- str(config.frontend_host),
- "--frontend-port",
- str(config.frontend_port),
- "--install-root",
- str(install_root),
- "--uv-path",
- uv_path,
- "--sync-timeout",
- str(sync_timeout),
- "--version",
- version,
- "--current-version",
- current_version,
]
- if backup_path is not None:
- argv.extend(["--backup-path", str(backup_path)])
+ if wait_for_parent:
+ argv.extend(["--parent-pid", str(os.getpid())])
+ argv.extend(
+ [
+ "--backend-host",
+ str(config.backend_host),
+ "--backend-port",
+ str(config.backend_port),
+ "--frontend-host",
+ str(config.frontend_host),
+ "--frontend-port",
+ str(config.frontend_port),
+ "--install-root",
+ str(install_root),
+ "--uv-path",
+ uv_path,
+ "--sync-timeout",
+ str(sync_timeout),
+ "--version",
+ version,
+ "--current-version",
+ current_version,
+ ]
+ )
+ if source_upgrade:
+ argv.extend(
+ [
+ "--mode",
+ "upgrade",
+ "--content-root",
+ str(content_root),
+ "--backup-path",
+ str(backup_path),
+ "--service-config-json",
+ json.dumps(service_config_payload(config), ensure_ascii=True, sort_keys=True),
+ ]
+ )
+ if service_snapshot.was_running:
+ argv.append("--was-running")
+ if service_snapshot.daemon_pid is not None:
+ argv.extend(["--daemon-pid", str(service_snapshot.daemon_pid)])
if uv_default_index:
argv.extend(["--uv-default-index", uv_default_index])
if npm_registry:
@@ -3684,8 +3082,6 @@ def _build_restart_handoff_argv(
argv.extend(["--bundle-sha256", bundle_sha256])
if cleanup_dir is not None:
argv.extend(["--cleanup-dir", str(cleanup_dir)])
- if prepare_handover:
- argv.append("--prepare-handover")
argv.extend(["--", *managed_restart_argv])
return argv
diff --git a/tests/cli/test_doctor_command.py b/tests/cli/test_doctor_command.py
index dea132de8..b9f21513a 100644
--- a/tests/cli/test_doctor_command.py
+++ b/tests/cli/test_doctor_command.py
@@ -12,17 +12,16 @@ async def _noop_log_init(**_: object) -> None:
return None
-def test_doctor_runs_source_installer_from_source_root(monkeypatch, tmp_path) -> None:
+def test_doctor_runs_core_installer_from_source_root(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(cli_main.Log, "init", _noop_log_init)
- calls: list[tuple[list[str], object, bool]] = []
-
- def fake_run(command, *, cwd, check, env):
- _ = env
- calls.append((command, cwd, check))
-
- monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run)
+ calls: list[object] = []
+ monkeypatch.setattr(
+ doctor_cmd,
+ "_run_core_install",
+ lambda source_root, **_kwargs: calls.append(source_root),
+ )
monkeypatch.setattr(
"flocks.cli.service_manager.build_status_lines",
lambda: [
@@ -35,16 +34,30 @@ def fake_run(command, *, cwd, check, env):
assert result.exit_code == 0, result.stdout
assert "Flocks source directory:" in result.stdout
- assert "scripts/install.sh" in result.stdout
+ assert "Repairing Flocks core installation" in result.stdout
assert "安装正常" in result.stdout
assert "运行状态正常" in result.stdout
assert len(calls) == 1
- command, cwd, check = calls[0]
- assert command[0] == "bash"
- assert command[1].endswith("scripts/install.sh")
- assert cwd == doctor_cmd._find_source_root()
- assert check is True
+ assert calls == [doctor_cmd._find_source_root()]
+
+
+def test_doctor_uses_shared_core_install_entry(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path))
+ monkeypatch.setattr(cli_main.Log, "init", _noop_log_init)
+ calls: list[object] = []
+
+ monkeypatch.setattr(
+ doctor_cmd,
+ "_run_core_install",
+ lambda source_root, **_kwargs: calls.append(source_root),
+ )
+ monkeypatch.setattr(doctor_cmd, "_print_service_diagnosis", lambda: None)
+
+ result = runner.invoke(cli_main.app, ["doctor"])
+
+ assert result.exit_code == 0, result.stdout
+ assert calls == [doctor_cmd._find_source_root()]
def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path) -> None:
@@ -54,14 +67,11 @@ def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path
monkeypatch.setattr(cli_main.Log, "init", _noop_log_init)
captured: dict[str, object] = {}
-
- def fake_run(command, *, cwd, check, env):
- captured["command"] = command
- captured["cwd"] = cwd
- captured["check"] = check
- captured["env"] = env
-
- monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run)
+ monkeypatch.setattr(
+ doctor_cmd,
+ "_run_core_install",
+ lambda source_root, *, env: captured.update(source_root=source_root, env=env),
+ )
monkeypatch.setattr("flocks.cli.service_manager.build_status_lines", lambda: ["[flocks] 后端未运行", "[flocks] WebUI 未运行"])
result = runner.invoke(cli_main.app, ["doctor"])
@@ -99,7 +109,7 @@ def fail_run(*_args, **_kwargs):
result = runner.invoke(cli_main.app, ["doctor"])
assert result.exit_code == 0, result.stdout
- assert "scripts/install.ps1" in result.stdout
+ assert "Repairing Flocks core installation" in result.stdout
assert "installer will continue in this console" in result.stdout
assert captured["cwd"] == doctor_cmd._find_source_root()
assert captured["close_fds"] is True
@@ -121,16 +131,14 @@ def test_doctor_windows_handoff_child_runs_installer_synchronously(monkeypatch,
captured: dict[str, object] = {}
- def fake_run(command, *, cwd, check, env):
- captured["command"] = command
- captured["cwd"] = cwd
- captured["check"] = check
- captured["env"] = env
-
def fail_popen(*_args, **_kwargs):
raise AssertionError("handoff child should run the installer directly")
- monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run)
+ monkeypatch.setattr(
+ doctor_cmd,
+ "_run_core_install",
+ lambda source_root, *, env: captured.update(source_root=source_root, env=env),
+ )
monkeypatch.setattr(doctor_cmd.subprocess, "Popen", fail_popen)
monkeypatch.setattr(
"flocks.cli.service_manager.build_status_lines",
@@ -143,11 +151,7 @@ def fail_popen(*_args, **_kwargs):
result = runner.invoke(cli_main.app, ["doctor"])
assert result.exit_code == 0, result.stdout
- command = captured["command"]
- assert isinstance(command, list)
- assert command[-1].endswith("scripts/install.ps1")
- assert captured["cwd"] == doctor_cmd._find_source_root()
- assert captured["check"] is True
+ assert captured["source_root"] == doctor_cmd._find_source_root()
assert "安装正常" in result.stdout
assert "运行状态正常" in result.stdout
@@ -175,18 +179,3 @@ def test_service_status_is_healthy_accepts_legacy_backend_and_webui() -> None:
]
)
assert not doctor_cmd._service_status_is_healthy(["[flocks] 后端运行中: PID=111", "[flocks] WebUI 未运行"])
-
-
-def test_doctor_builds_windows_install_command(monkeypatch) -> None:
- monkeypatch.setattr(doctor_cmd.shutil, "which", lambda name: None)
-
- command = doctor_cmd._build_source_install_command(doctor_cmd._find_source_root() / "scripts" / "install.ps1")
-
- assert command == [
- "powershell",
- "-NoProfile",
- "-ExecutionPolicy",
- "Bypass",
- "-File",
- str(doctor_cmd._find_source_root() / "scripts" / "install.ps1"),
- ]
diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py
index d75c1cac0..7bb3b3893 100644
--- a/tests/cli/test_service_manager.py
+++ b/tests/cli/test_service_manager.py
@@ -15,7 +15,6 @@
from tests.helpers.service_supervisor import (
SleeperProcessAdapter,
make_short_runtime_root,
- wait_for_process_exit,
)
@@ -30,7 +29,6 @@ def print(self, *args, **kwargs) -> None:
@pytest.fixture(autouse=True)
def _skip_backend_webui_dist_check(monkeypatch) -> None:
monkeypatch.setattr(service_manager, "_ensure_webui_dist", lambda *_args, **_kwargs: None)
- monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", lambda *_args, **_kwargs: {"action": "noop", "error": None})
def _make_runtime_paths(tmp_path: Path) -> service_manager.RuntimePaths:
@@ -973,27 +971,22 @@ def test_start_all_starts_supervisor_when_control_api_is_down(monkeypatch) -> No
assert call_order == ["ensure_runtime_dirs", "_start_all_without_stop"]
-def test_start_all_resolves_upgrade_runtime_before_supervisor_status(monkeypatch) -> None:
+def test_start_all_checks_supervisor_before_starting(monkeypatch) -> None:
events: list[str] = []
console = DummyConsole()
paths = _make_runtime_paths(Path("/tmp/flocks-test"))
- def resolve_upgrade_runtime(_console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]:
- events.append(f"upgrade:{frontend_port}:{attempt_recover}")
- return {"action": "cleaned", "error": None}
-
def supervisor_running(_paths) -> bool:
events.append("supervisor")
return False
monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths)
- monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", resolve_upgrade_runtime)
monkeypatch.setattr(service_manager, "supervisor_is_running", supervisor_running)
monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: events.append("start"))
service_manager.start_all(service_manager.ServiceConfig(frontend_port=5173), console)
- assert events == ["upgrade:5173:False", "supervisor", "start"]
+ assert events == ["supervisor", "start"]
def test_start_all_does_not_duplicate_running_supervisor(monkeypatch) -> None:
@@ -1613,47 +1606,6 @@ def test_supervisor_rejects_static_webui_stop_control_api(monkeypatch, tmp_path:
assert exc_info.value.response.status_code == 409
-@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API")
-def test_supervisor_upgrade_prepare_control_api_pauses_real_child_restart(monkeypatch, tmp_path: Path) -> None:
- del tmp_path
- short_root = make_short_runtime_root("flocks-supervisor-")
- paths = _make_runtime_paths(short_root)
- paths.run_dir.mkdir(parents=True)
- paths.log_dir.mkdir(parents=True)
- monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths)
- backend_adapter = SleeperProcessAdapter()
- daemon = service_supervisor.SupervisorDaemon(
- service_manager.ServiceConfig(backend_port=9995, frontend_port=9996),
- backend_adapter=backend_adapter,
- )
- daemon._start_control_server()
-
- try:
- daemon.restart_all(reason="test startup")
- backend_process = daemon.backend.process
- assert backend_process is not None
- assert daemon.webui.process is None
- assert daemon.webui.state == "static"
-
- status = service_control.request_prepare_upgrade(paths=paths)
-
- wait_for_process_exit(backend_process)
- assert status.backend.paused is True
- assert status.webui.paused is True
- assert daemon.backend.process is None
- assert backend_process.pid in backend_adapter.stopped
-
- daemon.tick()
-
- assert len(backend_adapter.started) == 1
- assert daemon.backend.process is None
- assert daemon.status_payload()["backend"]["paused"] is True
- finally:
- daemon.shutdown_children()
- daemon._stop_control_server()
- shutil.rmtree(short_root, ignore_errors=True)
-
-
def test_build_webui_dist_tolerates_windows_node_assertion_after_build(monkeypatch, tmp_path: Path) -> None:
webui_dir = tmp_path / "webui"
webui_dist = webui_dir / "dist"
diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py
index 76b5e971e..5dd268b15 100644
--- a/tests/cli/test_update_command.py
+++ b/tests/cli/test_update_command.py
@@ -7,7 +7,6 @@
import flocks.cli.commands.update as update_cmd
import flocks.cli.main as cli_main
-import flocks.cli.service_manager as service_manager
import flocks.updater as updater_pkg
from flocks.updater.models import UpdateProgress, VersionInfo
@@ -18,10 +17,10 @@ async def _noop_log_init(**_: object) -> None:
return None
-def test_updater_package_exports_build_updated_frontend() -> None:
+def test_updater_package_exports_shared_installer() -> None:
from flocks.updater import updater as updater_module
- assert updater_pkg.build_updated_frontend is updater_module.build_updated_frontend
+ assert updater_pkg.install_or_repair_source is updater_module.install_or_repair_source
def test_update_cli_accepts_force_option(monkeypatch, tmp_path) -> None:
@@ -92,8 +91,6 @@ def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch, t
check_regions: list[str | None] = []
confirm_prompts: list[str] = []
captured: dict[str, object] = {}
- stop_calls: list[str] = []
- build_calls: list[str | None] = []
answers = iter([True, True])
async def fake_check_update(*, locale: str | None = None, region: str | None = None) -> VersionInfo:
@@ -123,6 +120,7 @@ async def fake_perform_update(
restart: bool = True,
locale: str | None = None,
region: str | None = None,
+ wait_for_handoff: bool = False,
):
captured["latest_tag"] = latest_tag
captured["zipball_url"] = zipball_url
@@ -131,6 +129,7 @@ async def fake_perform_update(
captured["bundle_format"] = bundle_format
captured["perform_region"] = region
captured["restart"] = restart
+ captured["wait_for_handoff"] = wait_for_handoff
async for step in _fake_progress():
yield step
@@ -138,18 +137,10 @@ def fake_confirm(prompt: str, default: bool = False) -> bool:
confirm_prompts.append(prompt)
return next(answers)
- def fake_stop_all(console) -> None:
- stop_calls.append("stop")
-
- async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None:
- build_calls.append(region)
-
monkeypatch.setattr(updater_pkg, "check_update", fake_check_update)
monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update)
- monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend)
monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source")
monkeypatch.setattr(update_cmd.typer, "confirm", fake_confirm)
- monkeypatch.setattr(service_manager, "stop_all", fake_stop_all)
import asyncio
@@ -157,8 +148,6 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str
assert check_regions == ["cn"]
assert confirm_prompts == ["\n是否使用中国镜像进行升级?", "\n是否立即升级?"]
- assert stop_calls == ["stop"]
- assert build_calls == ["cn"]
assert captured == {
"latest_tag": "2026.4.2",
"zipball_url": "https://gitee.example.com/flocks.zip",
@@ -166,13 +155,17 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str
"bundle_sha256": None,
"bundle_format": None,
"perform_region": "cn",
- "restart": False,
+ "restart": True,
+ "wait_for_handoff": True,
}
assert "已切换为中国镜像源" not in output.getvalue()
async def _fake_progress():
yield UpdateProgress(stage="fetching", message="fetching")
+ yield UpdateProgress(stage="backing_up", message="backing up")
+ yield UpdateProgress(stage="applying", message="applying")
+ yield UpdateProgress(stage="restarting", message="restarting")
yield UpdateProgress(stage="done", message="done", success=True)
@@ -197,8 +190,6 @@ async def fake_check_update(*, locale: str | None = None, region: str | None = N
)
captured: dict[str, object] = {}
- stop_calls: list[str] = []
- build_calls: list[str | None] = []
async def fake_perform_update(
latest_tag: str,
@@ -210,6 +201,7 @@ async def fake_perform_update(
restart: bool = True,
locale: str | None = None,
region: str | None = None,
+ wait_for_handoff: bool = False,
):
captured["latest_tag"] = latest_tag
captured["zipball_url"] = zipball_url
@@ -218,20 +210,13 @@ async def fake_perform_update(
captured["bundle_format"] = bundle_format
captured["perform_region"] = region
captured["restart"] = restart
+ captured["wait_for_handoff"] = wait_for_handoff
async for step in _fake_progress():
yield step
- def fake_stop_all(console) -> None:
- stop_calls.append("stop")
-
- async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None:
- build_calls.append(region)
-
monkeypatch.setattr(updater_pkg, "check_update", fake_check_update)
monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update)
- monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend)
monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source")
- monkeypatch.setattr(service_manager, "stop_all", fake_stop_all)
import asyncio
@@ -245,15 +230,16 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str
"bundle_format": None,
"check_region": "cn",
"perform_region": "cn",
- "restart": False,
+ "restart": True,
+ "wait_for_handoff": True,
}
- assert stop_calls == ["stop"]
- assert build_calls == ["cn"]
assert "强制重新安装 v2026.4.2" in output.getvalue()
+ assert "[3/3] 应用新版本... ✓" in output.getvalue()
+ assert "重启服务" not in output.getvalue()
assert "升级完成" in output.getvalue()
-def test_update_executes_flocks_stop_before_upgrade(monkeypatch, tmp_path) -> None:
+def test_update_delegates_stop_install_build_and_restart_to_handoff(monkeypatch, tmp_path) -> None:
output = StringIO()
monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path))
monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False)
@@ -288,8 +274,9 @@ async def fake_perform_update(
restart: bool = True,
locale: str | None = None,
region: str | None = None,
+ wait_for_handoff: bool = False,
):
- events.append("perform_update")
+ events.append(f"perform_update:{wait_for_handoff}")
async for step in _fake_progress():
yield step
@@ -297,29 +284,21 @@ def fake_confirm(prompt: str, default: bool = False) -> bool:
confirm_prompts.append(prompt)
return next(answers)
- def fake_stop_all(console) -> None:
- events.append("stop")
-
- async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None:
- events.append("build")
-
monkeypatch.setattr(updater_pkg, "check_update", fake_check_update)
monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update)
- monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend)
monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source")
monkeypatch.setattr(update_cmd.typer, "confirm", fake_confirm)
- monkeypatch.setattr(service_manager, "stop_all", fake_stop_all)
import asyncio
asyncio.run(update_cmd._update(check=False, yes=False, force=False, region=None))
assert confirm_prompts == ["\n是否使用中国镜像进行升级?", "\n是否立即升级?"]
- assert events == ["stop", "perform_update", "build"]
- assert "已执行 flocks stop" in output.getvalue()
+ assert events == ["perform_update:True"]
+ assert "已执行 flocks stop" not in output.getvalue()
-def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch, tmp_path) -> None:
+def test_update_reports_handoff_preparation_failure(monkeypatch, tmp_path) -> None:
output = StringIO()
monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path))
monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False)
@@ -350,21 +329,13 @@ async def fake_perform_update(
restart: bool = True,
locale: str | None = None,
region: str | None = None,
+ wait_for_handoff: bool = False,
):
- async for step in _fake_progress():
- yield step
-
- def fake_stop_all(console) -> None:
- return None
-
- async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None:
- raise RuntimeError("npm run build failed")
+ yield UpdateProgress(stage="error", message="handoff preparation failed", success=False)
monkeypatch.setattr(updater_pkg, "check_update", fake_check_update)
monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update)
- monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend)
monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source")
- monkeypatch.setattr(service_manager, "stop_all", fake_stop_all)
import asyncio
@@ -372,4 +343,4 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str
asyncio.run(update_cmd._update(check=False, yes=True, force=False, region=None))
assert excinfo.value.exit_code == 1
- assert "前端构建失败" in output.getvalue()
+ assert "handoff preparation failed" in output.getvalue()
diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py
index d4dc0b55e..b7c0c94df 100644
--- a/tests/server/routes/test_console_upgrade_routes.py
+++ b/tests/server/routes/test_console_upgrade_routes.py
@@ -1522,7 +1522,7 @@ async def test_auto_activate_reinstalls_when_existing_pro_marker_is_not_target_b
async def _fake_perform_pro_bundle_install(*args, **kwargs):
assert args == ()
- assert kwargs["restart"] is False
+ assert kwargs["restart"] is True
marker_state["payload"] = {
"release_id": "rel_20260605",
"bundle_release_id": "rel_20260605",
@@ -1627,7 +1627,7 @@ async def test_auto_activate_installs_pro_bundle_when_core_version_is_latest(
async def _fake_perform_pro_bundle_install(*args, **kwargs):
nonlocal installed
assert args == ()
- assert kwargs["restart"] is False
+ assert kwargs["restart"] is True
yield UpdateProgress(stage="syncing", message="Installing Flocks Pro component...", success=None)
installed = True
yield UpdateProgress(stage="done", message="Flocks Pro component installed from v2026.5.9", success=True)
diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py
index de6677698..14e8741f4 100644
--- a/tests/server/test_lifespan.py
+++ b/tests/server/test_lifespan.py
@@ -19,7 +19,7 @@ def warn(self, *_args, **_kwargs) -> None:
@pytest.mark.asyncio
-async def test_lifespan_cleans_leftovers_before_recovering_upgrade_state(
+async def test_lifespan_cleans_replaced_files_without_upgrade_recovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
events: list[str] = []
@@ -27,9 +27,6 @@ async def test_lifespan_cleans_leftovers_before_recovering_upgrade_state(
def cleanup_replaced_files() -> None:
events.append("cleanup_replaced_files")
- def recover_upgrade_state() -> None:
- events.append("recover_upgrade_state")
-
async def fake_storage_init() -> None:
return None
@@ -56,7 +53,6 @@ async def fake_async_noop(*_args, **_kwargs) -> None:
"flocks.updater.updater",
types.SimpleNamespace(
cleanup_replaced_files=cleanup_replaced_files,
- recover_upgrade_state=recover_upgrade_state,
),
)
monkeypatch.setitem(
@@ -149,4 +145,4 @@ async def fake_async_noop(*_args, **_kwargs) -> None:
async with app_module.lifespan(SimpleNamespace()):
pass
- assert events == ["cleanup_replaced_files", "recover_upgrade_state"]
+ assert events == ["cleanup_replaced_files"]
diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py
index e9df3cbb7..b044d3baa 100644
--- a/tests/updater/test_restart_handoff.py
+++ b/tests/updater/test_restart_handoff.py
@@ -1,4 +1,6 @@
+import json
import shutil
+import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
@@ -6,11 +8,17 @@
import pytest
from flocks.cli import service_manager
+from flocks.cli.service_config import service_config_payload
from flocks.updater import restart_handoff
-from tests.helpers.service_supervisor import make_short_runtime_root, start_supervisor, stop_supervisor, wait_for_supervisor
+from tests.helpers.service_supervisor import (
+ make_short_runtime_root,
+ start_supervisor,
+ stop_supervisor,
+ wait_for_supervisor,
+)
-def _handoff_args(tmp_path: Path, restart_argv: list[str], *, prepare_handover: bool = False) -> list[str]:
+def _handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]:
args = [
"--parent-pid",
"1234",
@@ -33,11 +41,423 @@ def _handoff_args(tmp_path: Path, restart_argv: list[str], *, prepare_handover:
"--current-version",
"2026.3.31",
]
- if prepare_handover:
- args.append("--prepare-handover")
return [*args, "--", *restart_argv]
+def _simple_upgrade_handoff_args(tmp_path: Path) -> list[str]:
+ content_root = tmp_path / "staged"
+ content_root.mkdir()
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+ config = service_manager.ServiceConfig(
+ backend_host="10.0.0.8",
+ backend_port=5273,
+ frontend_host="10.0.0.8",
+ frontend_port=5273,
+ legacy_backend_host="0.0.0.0",
+ legacy_backend_port=9000,
+ no_browser=True,
+ skip_frontend_build=True,
+ )
+ return [
+ "--mode",
+ "upgrade",
+ "--parent-pid",
+ "1234",
+ "--backend-host",
+ config.backend_host,
+ "--backend-port",
+ str(config.backend_port),
+ "--frontend-host",
+ config.frontend_host,
+ "--frontend-port",
+ str(config.frontend_port),
+ "--install-root",
+ str(tmp_path / "install"),
+ "--content-root",
+ str(content_root),
+ "--backup-path",
+ str(backup_path),
+ "--was-running",
+ "--daemon-pid",
+ "2468",
+ "--service-config-json",
+ json.dumps(service_config_payload(config)),
+ "--uv-path",
+ "uv",
+ "--sync-timeout",
+ "300",
+ "--version",
+ "2026.4.1",
+ "--current-version",
+ "2026.3.31",
+ "--",
+ "/install/.venv/bin/python",
+ ]
+
+
+def _v2026_7_1_handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]:
+ """Build the handoff protocol emitted by the v2026.7.1 updater."""
+ args = _handoff_args(tmp_path, restart_argv)
+ args[args.index("--install-root") : args.index("--install-root")] = [
+ "--backend-pid-file",
+ str(tmp_path / "backend.pid"),
+ ]
+ separator_index = args.index("--")
+ args[separator_index:separator_index] = [
+ "--backup-path",
+ str(tmp_path / "backup.tar.gz"),
+ ]
+ return args
+
+
+def _v2026_7_15_handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]:
+ """Build the handoff protocol emitted by the v2026.7.15 updater."""
+ args = _handoff_args(tmp_path, restart_argv)
+ args[args.index("--backend-port") + 1] = "5173"
+ separator_index = args.index("--")
+ args[separator_index:separator_index] = [
+ "--backup-path",
+ str(tmp_path / "backup.tar.gz"),
+ "--prepare-handover",
+ ]
+ return args
+
+
+def test_current_version_upgrade_handoff_stops_replaces_installs_and_restarts(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ events: list[str] = []
+ cleanup_dir = tmp_path / "cleanup"
+ cleanup_dir.mkdir()
+ args = _simple_upgrade_handoff_args(tmp_path)
+ args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)]
+
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_services_before_upgrade",
+ lambda args: events.append(f"stop:{args.daemon_pid}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_apply_new_source",
+ lambda args: events.append("replace"),
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_run_upgrade_tasks",
+ lambda args: events.append("install") or None,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_start_service_after_upgrade",
+ lambda args: events.append("start") or (True, "", ""),
+ )
+ monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **_kwargs: None)
+
+ assert restart_handoff.run(args) == 0
+ assert events == [
+ "wait-parent:1234",
+ "stop:2468",
+ "replace",
+ "install",
+ "start",
+ ]
+ assert not cleanup_dir.exists()
+
+
+def test_current_version_upgrade_handoff_can_run_without_waiting_for_parent(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ events: list[str] = []
+ args = _simple_upgrade_handoff_args(tmp_path)
+ parent_index = args.index("--parent-pid")
+ del args[parent_index : parent_index + 2]
+
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda _parent_pid: pytest.fail("parent wait must be skipped"),
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_services_before_upgrade",
+ lambda _args: events.append("stop") or True,
+ )
+ monkeypatch.setattr(restart_handoff, "_apply_new_source", lambda _args: events.append("replace"))
+ monkeypatch.setattr(
+ restart_handoff,
+ "_run_upgrade_tasks",
+ lambda _args: events.append("install") or None,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_start_service_after_upgrade",
+ lambda _args: events.append("start") or (True, "", ""),
+ )
+ monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **_kwargs: None)
+
+ assert restart_handoff.run(args) == 0
+ assert events == ["stop", "replace", "install", "start"]
+
+
+@pytest.mark.parametrize(
+ ("host", "port"),
+ [
+ ("127.0.0.1", 5173),
+ ("0.0.0.0", 5273),
+ ("10.20.30.40", 9527),
+ ("::", 6173),
+ ("2001:db8::20", 7173),
+ ],
+)
+def test_upgrade_start_argv_uses_captured_host_port_without_control_api(host: str, port: int) -> None:
+ config = service_manager.ServiceConfig(
+ backend_host=host,
+ backend_port=port,
+ frontend_host=host,
+ frontend_port=port,
+ legacy_backend_host="0.0.0.0",
+ legacy_backend_port=9000,
+ no_browser=True,
+ skip_frontend_build=True,
+ )
+ args = SimpleNamespace(
+ restart_argv=["/install/.venv/bin/python"],
+ service_config_json=json.dumps(service_config_payload(config)),
+ )
+
+ assert restart_handoff._build_captured_start_argv(args) == [
+ "/install/.venv/bin/python",
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--host",
+ host,
+ "--port",
+ str(port),
+ "--no-browser",
+ "--skip-webui-build",
+ "--server-host",
+ "0.0.0.0",
+ "--server-port",
+ "9000",
+ ]
+
+
+@pytest.mark.parametrize(
+ ("public_host", "public_port"),
+ [
+ ("0.0.0.0", 5173),
+ ("10.20.30.40", 9527),
+ ("::", 6173),
+ ("2001:db8::20", 7173),
+ ],
+)
+def test_upgrade_start_argv_preserves_distinct_legacy_public_endpoint(
+ public_host: str,
+ public_port: int,
+) -> None:
+ config = service_manager.ServiceConfig(
+ backend_host="127.0.0.1",
+ backend_port=8000,
+ frontend_host=public_host,
+ frontend_port=public_port,
+ legacy_backend_host="127.0.0.1",
+ legacy_backend_port=8000,
+ no_browser=True,
+ skip_frontend_build=True,
+ )
+ args = SimpleNamespace(
+ restart_argv=["/install/.venv/bin/python"],
+ service_config_json=json.dumps(service_config_payload(config)),
+ )
+
+ assert restart_handoff._build_captured_start_argv(args) == [
+ "/install/.venv/bin/python",
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--host",
+ public_host,
+ "--port",
+ str(public_port),
+ "--no-browser",
+ "--skip-webui-build",
+ "--server-host",
+ "127.0.0.1",
+ "--server-port",
+ "8000",
+ ]
+
+
+def test_upgrade_wait_ports_exclude_legacy_cleanup_port(tmp_path: Path) -> None:
+ args = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path))
+
+ assert restart_handoff._service_ports(args) == (5273,)
+
+
+def test_upgrade_install_failure_keeps_backup_and_temp_without_restart_or_rollback(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ events: list[str] = []
+ results: list[dict[str, object]] = []
+ cleanup_dir = tmp_path / "cleanup"
+ cleanup_dir.mkdir()
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+ args = _simple_upgrade_handoff_args(tmp_path)
+ args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)]
+
+ monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda _pid: True)
+ monkeypatch.setattr(restart_handoff, "_stop_services_before_upgrade", lambda _args: True)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_apply_new_source",
+ lambda _args: events.append("replace"),
+ )
+ monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda _args: "npm build failed")
+ monkeypatch.setattr(
+ restart_handoff,
+ "_start_service_after_upgrade",
+ lambda _args: events.append("start") or (True, "", ""),
+ )
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(message))
+ monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **kwargs: results.append(kwargs))
+
+ assert restart_handoff.run(args) == 1
+ assert events[0] == "replace"
+ assert "start" not in events
+ assert not any("rollback" in event for event in events)
+ assert cleanup_dir.exists()
+ assert backup_path.exists()
+ assert results[-1]["failed_stage"] == "install"
+ assert results[-1]["backup_path"] == backup_path
+
+
+def test_upgrade_start_failure_records_process_output_and_keeps_temp(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ results: list[dict[str, object]] = []
+ cleanup_dir = tmp_path / "cleanup"
+ cleanup_dir.mkdir()
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+ args = _simple_upgrade_handoff_args(tmp_path)
+ args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)]
+
+ monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda _pid: True)
+ monkeypatch.setattr(restart_handoff, "_stop_services_before_upgrade", lambda _args: True)
+ monkeypatch.setattr(restart_handoff, "_apply_new_source", lambda _args: None)
+ monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda _args: None)
+ monkeypatch.setattr(restart_handoff, "_report_pending_pro_bundle_install_receipt", lambda _args: None)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_start_service_after_upgrade",
+ lambda _args: (False, "start stdout", "start stderr"),
+ )
+ monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **kwargs: results.append(kwargs))
+
+ assert restart_handoff.run(args) == 1
+ assert cleanup_dir.exists()
+ assert results[-1]["failed_stage"] == "start"
+ assert results[-1]["stdout"] == "start stdout"
+ assert results[-1]["stderr"] == "start stderr"
+
+
+def test_upgrade_writes_running_after_parent_exits(monkeypatch, tmp_path: Path) -> None:
+ events: list[str] = []
+ args = _simple_upgrade_handoff_args(tmp_path)
+
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda _pid: events.append("parent-exited") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_write_upgrade_result",
+ lambda **kwargs: events.append(f"write:{kwargs['phase']}"),
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_services_before_upgrade",
+ lambda _args: events.append("stop") or False,
+ )
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
+
+ assert restart_handoff.run(args) == 1
+ assert events[:3] == ["parent-exited", "write:running", "stop"]
+
+
+def test_upgrade_result_write_failure_is_non_fatal(monkeypatch, tmp_path: Path) -> None:
+ events: list[str] = []
+ args = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path))
+
+ monkeypatch.setattr(
+ restart_handoff.updater_module,
+ "_write_upgrade_result_state",
+ lambda _payload: (_ for _ in ()).throw(OSError("disk unavailable")),
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_record_handoff_log",
+ lambda message: events.append(message),
+ )
+
+ restart_handoff._write_upgrade_result(args=args, phase="running")
+
+ assert events == ["upgrade_result_write_failed phase=running error=disk unavailable"]
+
+
+def test_upgrade_that_was_stopped_does_not_start_service(monkeypatch, tmp_path: Path) -> None:
+ args = _simple_upgrade_handoff_args(tmp_path)
+ args.remove("--was-running")
+ parsed = restart_handoff._parse_args(args)
+ monkeypatch.setattr(
+ restart_handoff.subprocess,
+ "run",
+ lambda *_args, **_kwargs: pytest.fail("stopped service must remain stopped"),
+ )
+
+ assert restart_handoff._start_service_after_upgrade(parsed) == (True, "", "")
+
+
+def test_upgrade_start_decodes_invalid_windows_output_without_crashing(monkeypatch, tmp_path: Path) -> None:
+ parsed = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path))
+ logs: list[str] = []
+
+ def fake_run(command, **kwargs):
+ assert kwargs["text"] is False
+ return subprocess.CompletedProcess(
+ command,
+ 1,
+ stdout=b"start stdout \x80",
+ stderr=b"start stderr \x81",
+ )
+
+ monkeypatch.setattr(restart_handoff.subprocess, "run", fake_run)
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", logs.append)
+
+ assert restart_handoff._start_service_after_upgrade(parsed) == (
+ False,
+ "start stdout �",
+ "start stderr �",
+ )
+ assert logs == ["restart_failed returncode=1 stdout=start stdout � stderr=start stderr �"]
+
+
def test_run_waits_for_parent_and_backend_port_before_spawning(
monkeypatch,
tmp_path: Path,
@@ -75,8 +495,9 @@ def test_run_waits_for_parent_and_backend_port_before_spawning(
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
- lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}")
- or SimpleNamespace(pid=4321),
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None)
monkeypatch.setattr(
@@ -123,8 +544,9 @@ def test_run_keeps_current_start_restart_argv(monkeypatch, tmp_path: Path) -> No
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
- lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}")
- or SimpleNamespace(pid=4321),
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
)
code = restart_handoff.run(_handoff_args(tmp_path, restart_argv))
@@ -137,7 +559,7 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat
events: list[str] = []
restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
args = _handoff_args(tmp_path, restart_argv)
- args[args.index("--install-root"):args.index("--install-root")] = [
+ args[args.index("--install-root") : args.index("--install-root")] = [
"--backend-pid-file",
str(tmp_path / "backend.pid"),
]
@@ -146,12 +568,14 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None)
+ monkeypatch.setattr(restart_handoff, "_cleanup_legacy_upgrade_handover", lambda args: True)
monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True)
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
- lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}")
- or SimpleNamespace(pid=4321),
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
)
code = restart_handoff.run(args)
@@ -160,14 +584,122 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat
assert f"spawn:{restart_argv}:{tmp_path}:True" in events
-def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port(
+def test_v2026_7_1_upgrade_handoff_runs_tasks_and_restarts(monkeypatch, tmp_path: Path) -> None:
+ events: list[str] = []
+ flocks_root = tmp_path / "flocks-root"
+ upgrade_state = flocks_root / "run" / "upgrade-state.json"
+ upgrade_state.parent.mkdir(parents=True)
+ upgrade_state.write_text(
+ json.dumps(
+ {
+ "backend_host": "127.0.0.1",
+ "backend_port": 8000,
+ "frontend_host": "0.0.0.0",
+ "frontend_port": 8888,
+ }
+ ),
+ encoding="utf-8",
+ )
+ restart_argv = [
+ "python.exe",
+ "-m",
+ "flocks.cli.main",
+ "serve",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ "8000",
+ ]
+ expected_restart_argv = [
+ "python.exe",
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--no-browser",
+ "--skip-webui-build",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "8888",
+ "--server-host",
+ "127.0.0.1",
+ "--server-port",
+ "8000",
+ ]
+ args = _v2026_7_1_handoff_args(tmp_path, restart_argv)
+
+ monkeypatch.setattr(restart_handoff.updater_module, "_flocks_root", lambda: flocks_root)
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_ensure_backend_port_free",
+ lambda backend_port: events.append(f"free-port:{backend_port}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_run_upgrade_tasks",
+ lambda _args: events.append("install") or None,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_cleanup_legacy_upgrade_handover",
+ lambda _args: events.append("cleanup-handover") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_supervisor_before_restart",
+ lambda: events.append("stop-supervisor") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff.subprocess,
+ "Popen",
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
+ )
+
+ assert restart_handoff.run(args) == 0
+ assert events == [
+ "wait-parent:1234",
+ "free-port:8000",
+ "install",
+ "cleanup-handover",
+ "stop-supervisor",
+ f"spawn:{expected_restart_argv}:{tmp_path}:True",
+ ]
+
+
+def test_v2026_7_15_upgrade_handoff_stops_before_tasks_and_restarts(
monkeypatch,
tmp_path: Path,
) -> None:
events: list[str] = []
- restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
+ restart_argv = [
+ "python.exe",
+ "-m",
+ "flocks.cli.main",
+ "start",
+ "--no-browser",
+ "--skip-webui-build",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "5173",
+ "--server-host",
+ "127.0.0.1",
+ "--server-port",
+ "8000",
+ ]
+ args = _v2026_7_15_handoff_args(tmp_path, restart_argv)
+ args[args.index("--backend-host") + 1] = "0.0.0.0"
+ args[args.index("--frontend-host") + 1] = "0.0.0.0"
- monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}"))
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
monkeypatch.setattr(
restart_handoff,
"_wait_for_parent_exit",
@@ -175,8 +707,91 @@ def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port(
)
monkeypatch.setattr(
restart_handoff,
- "_prepare_upgrade_handover",
- lambda args: events.append(f"prepare:{args.version}") or True,
+ "_ensure_backend_port_free",
+ lambda _backend_port: pytest.fail("legacy handover must stop the supervisor first"),
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_supervisor_before_restart",
+ lambda **kwargs: events.append(f"stop-supervisor:{kwargs}") or True,
+ )
+ monkeypatch.setattr(restart_handoff, "_legacy_supervisor_pid", lambda _args: 2468)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_run_upgrade_tasks",
+ lambda _args: events.append("install") or None,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_cleanup_legacy_upgrade_handover",
+ lambda _args: events.append("cleanup-handover") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff.subprocess,
+ "Popen",
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
+ )
+
+ assert restart_handoff.run(args) == 0
+ assert events == [
+ "wait-parent:1234",
+ (
+ "stop-supervisor:{'daemon_pid': 2468, 'backend_port': 5173, "
+ "'service_ports': (5173,), 'force_daemon_stop': True}"
+ ),
+ "install",
+ "cleanup-handover",
+ f"spawn:{restart_argv}:{tmp_path}:True",
+ ]
+
+
+def test_cleanup_legacy_upgrade_handover_stops_trusted_page(monkeypatch, tmp_path: Path) -> None:
+ run_dir = tmp_path / "run"
+ page_dir = run_dir / "upgrade-page"
+ page_dir.mkdir(parents=True)
+ state_path = run_dir / "upgrade-state.json"
+ state_path.write_text("{}", encoding="utf-8")
+ pid_path = run_dir / "upgrade_server.pid"
+ pid_path.write_text("2468", encoding="utf-8")
+ alive = {2468}
+
+ monkeypatch.setattr(restart_handoff.updater_module, "_flocks_root", lambda: tmp_path)
+ monkeypatch.setattr(
+ restart_handoff.service_manager,
+ "port_owner_pids",
+ lambda _port: sorted(alive),
+ )
+ monkeypatch.setattr(
+ restart_handoff.service_manager,
+ "_process_command_line",
+ lambda pid: f"python -m http.server --directory {page_dir}" if pid in alive else "",
+ )
+ monkeypatch.setattr(
+ restart_handoff.service_manager,
+ "_terminate_orphan_pid",
+ lambda pid, _label, _console: alive.discard(pid),
+ )
+
+ assert restart_handoff._cleanup_legacy_upgrade_handover(SimpleNamespace(frontend_port=5173))
+ assert not state_path.exists()
+ assert not pid_path.exists()
+ assert not page_dir.exists()
+
+
+def test_restart_only_waits_for_port_after_parent_exit(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ events: list[str] = []
+ restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
+
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}"))
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True,
)
monkeypatch.setattr(
restart_handoff,
@@ -192,16 +807,17 @@ def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port(
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
- lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}")
- or SimpleNamespace(pid=4321),
+ lambda argv, cwd=None, close_fds=False: (
+ events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321)
+ ),
)
- code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True))
+ code = restart_handoff.run(_handoff_args(tmp_path, restart_argv))
assert code == 0
assert events[1:] == [
"wait-parent:1234",
- "prepare:2026.4.1",
+ "free-port:8000",
"tasks",
"stop-supervisor",
f"spawn:{restart_argv}:{tmp_path}:True",
@@ -223,7 +839,8 @@ def test_run_reports_pending_install_receipt_after_pro_bundle_tasks(
monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}"))
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
- monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True)
+ monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda **_kwargs: True)
+ monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None)
monkeypatch.setattr(
restart_handoff,
@@ -235,12 +852,66 @@ def test_run_reports_pending_install_receipt_after_pro_bundle_tasks(
"Popen",
lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}") or SimpleNamespace(pid=4321),
)
- monkeypatch.setattr(restart_handoff, "_record_backend_runtime_if_direct_serve", lambda *_args, **_kwargs: None)
-
code = restart_handoff.run(args)
assert code == 0
- assert events[1:4] == ["tasks", "receipt", f"spawn:{restart_argv}"]
+ assert events.index("tasks") < events.index("receipt")
+ assert any(event.startswith("spawn:") for event in events)
+
+
+def test_pro_bundle_handoff_stops_supervisor_before_port_check_and_install(
+ monkeypatch,
+ tmp_path: Path,
+) -> None:
+ events: list[str] = []
+ restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
+ pro_wheel = tmp_path / "flockspro.whl"
+ manifest = tmp_path / "manifest.json"
+ args = _handoff_args(tmp_path, restart_argv)
+ separator_index = args.index("--")
+ args[separator_index:separator_index] = [
+ "--pro-wheel-path",
+ str(pro_wheel),
+ "--pro-bundle-manifest-path",
+ str(manifest),
+ ]
+
+ monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None)
+ monkeypatch.setattr(
+ restart_handoff,
+ "_wait_for_parent_exit",
+ lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_stop_supervisor_before_restart",
+ lambda **kwargs: events.append(f"stop-supervisor:{kwargs}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_ensure_backend_port_free",
+ lambda backend_port: events.append(f"free-port:{backend_port}") or True,
+ )
+ monkeypatch.setattr(
+ restart_handoff,
+ "_run_upgrade_tasks",
+ lambda _args: events.append("install") or None,
+ )
+ monkeypatch.setattr(restart_handoff, "_report_pending_pro_bundle_install_receipt", lambda _args: None)
+ monkeypatch.setattr(
+ restart_handoff.subprocess,
+ "Popen",
+ lambda _argv, **_kwargs: events.append("spawn") or SimpleNamespace(pid=4321),
+ )
+
+ assert restart_handoff.run(args) == 0
+ assert events == [
+ "wait-parent:1234",
+ "stop-supervisor:{'backend_port': 8000, 'service_ports': (5173,)}",
+ "free-port:8000",
+ "install",
+ "spawn",
+ ]
def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Path) -> None:
@@ -258,7 +929,10 @@ def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Pa
code = restart_handoff.run(_handoff_args(tmp_path, ["python.exe", "-m", "flocks.cli.main", "start"]))
assert code == 1
- assert events == ["log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173", "log:parent_exit_timeout parent_pid=1234"]
+ assert events == [
+ "log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173",
+ "log:parent_exit_timeout parent_pid=1234",
+ ]
def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path) -> None:
@@ -269,7 +943,6 @@ def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path)
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: "sync failed")
- monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}"))
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
@@ -279,11 +952,11 @@ def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path)
code = restart_handoff.run(_handoff_args(tmp_path, restart_argv))
assert code == 1
- assert "rollback:sync failed" in events
+ assert "log:upgrade_tasks_failed error=sync failed" in events
assert "spawn" not in events
-def test_run_rolls_back_and_cleans_up_when_upgrade_tasks_crash(monkeypatch, tmp_path: Path) -> None:
+def test_run_logs_and_cleans_up_when_upgrade_tasks_crash(monkeypatch, tmp_path: Path) -> None:
events: list[str] = []
cleanup_dir = tmp_path / "cleanup"
cleanup_dir.mkdir()
@@ -300,7 +973,6 @@ def crash(_args):
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", crash)
- monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}"))
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
@@ -310,7 +982,7 @@ def crash(_args):
code = restart_handoff.run(args)
assert code == 1
- assert "rollback:upgrade tasks crashed: boom" in events
+ assert "log:upgrade_tasks_failed error=upgrade tasks crashed: boom" in events
assert not cleanup_dir.exists()
assert "spawn" not in events
@@ -337,50 +1009,78 @@ def test_run_does_not_spawn_when_supervisor_stop_fails(monkeypatch, tmp_path: Pa
assert "spawn" not in events
-def test_run_rolls_back_prepared_handover_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None:
+def test_restart_only_does_not_rollback_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None:
events: list[str] = []
restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}"))
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
- monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True)
+ monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda _port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None)
monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: False)
- monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover"))
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
lambda *_args, **_kwargs: events.append("spawn"),
)
- code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True))
+ code = restart_handoff.run(_handoff_args(tmp_path, restart_argv))
assert code == 1
- assert "rollback-handover" in events
+ assert not any("rollback" in event for event in events)
assert "spawn" not in events
-def test_run_rolls_back_prepared_handover_when_restart_spawn_fails(monkeypatch, tmp_path: Path) -> None:
+def test_restart_only_does_not_rollback_when_restart_spawn_fails(monkeypatch, tmp_path: Path) -> None:
events: list[str] = []
restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"]
monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}"))
monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True)
- monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True)
+ monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda _port: True)
monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None)
monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True)
- monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover"))
monkeypatch.setattr(
restart_handoff.subprocess,
"Popen",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("spawn failed")),
)
- code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True))
+ code = restart_handoff.run(_handoff_args(tmp_path, restart_argv))
assert code == 1
assert "log:restart_spawn_failed error=spawn failed" in events
- assert "rollback-handover" in events
+ assert not any("rollback" in event for event in events)
+
+
+def test_stop_supervisor_requests_stop_when_health_probe_is_temporarily_unavailable(monkeypatch) -> None:
+ from flocks.cli import service_control
+
+ events: list[str] = []
+ daemon_running = True
+
+ monkeypatch.setattr(service_control, "supervisor_is_running", lambda _paths: False)
+ monkeypatch.setattr(
+ restart_handoff.service_manager,
+ "pid_is_running",
+ lambda _pid: daemon_running,
+ )
+ monkeypatch.setattr(restart_handoff, "_backend_port_in_use", lambda _port: False)
+
+ def request_stop(*, paths, timeout):
+ del paths, timeout
+ nonlocal daemon_running
+ events.append("request-stop")
+ daemon_running = False
+
+ monkeypatch.setattr(service_control, "request_stop", request_stop)
+
+ assert restart_handoff._stop_supervisor_before_restart(
+ daemon_pid=2468,
+ timeout_seconds=0.01,
+ poll_interval_seconds=0.001,
+ )
+ assert events == ["request-stop"]
@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API")
diff --git a/tests/updater/test_restart_handoff_process.py b/tests/updater/test_restart_handoff_process.py
new file mode 100644
index 000000000..69c302a43
--- /dev/null
+++ b/tests/updater/test_restart_handoff_process.py
@@ -0,0 +1,281 @@
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+from flocks.cli import service_manager
+from flocks.cli.service_config import service_config_payload
+
+
+pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="uses Unix executable shims and symlinks")
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+LOCALE_HANDOFF_ARGS = [
+ pytest.param([], id="english-upgrade"),
+ pytest.param(
+ [
+ "--uv-default-index",
+ "https://mirrors.aliyun.com/pypi/simple",
+ "--npm-registry",
+ "https://registry.npmmirror.com/",
+ ],
+ id="chinese-upgrade",
+ ),
+]
+
+
+def _free_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _prepare_isolated_runtime(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
+ install_root = tmp_path / "install"
+ install_root.mkdir()
+ (install_root / "pyproject.toml").write_text(
+ '[project]\nname = "handoff-process-test"\nversion = "0.0.0"\nrequires-python = ">=3.12"\ndependencies = []\n',
+ encoding="utf-8",
+ )
+ runtime_python = install_root / ".venv" / "bin" / "python"
+ runtime_python.parent.mkdir(parents=True)
+ runtime_python.symlink_to(sys.executable)
+
+ uv_path = shutil.which("uv")
+ if uv_path is None:
+ pytest.skip("uv is required for the real handoff process test")
+
+ isolated_home = tmp_path / "home"
+ isolated_home.mkdir()
+ env = os.environ.copy()
+ env.update(
+ {
+ "FLOCKS_ROOT": str(tmp_path / "flocks-root"),
+ "HOME": str(isolated_home),
+ "UV_CACHE_DIR": str(tmp_path / "uv-cache"),
+ "UV_PYTHON_DOWNLOADS": "never",
+ }
+ )
+ env.pop("PYTHONPATH", None)
+ return install_root, Path(uv_path), env
+
+
+def _parent_process() -> subprocess.Popen[str]:
+ return subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(0.2)"],
+ text=True,
+ )
+
+
+def _base_handoff_command(
+ *,
+ parent_pid: int,
+ install_root: Path,
+ uv_path: Path,
+ backend_port: int,
+ frontend_port: int,
+ current_version: str,
+) -> list[str]:
+ return [
+ sys.executable,
+ "-m",
+ "flocks.updater.restart_handoff",
+ "--parent-pid",
+ str(parent_pid),
+ "--backend-host",
+ "127.0.0.1",
+ "--backend-port",
+ str(backend_port),
+ "--frontend-host",
+ "127.0.0.1",
+ "--frontend-port",
+ str(frontend_port),
+ "--install-root",
+ str(install_root),
+ "--uv-path",
+ str(uv_path),
+ "--sync-timeout",
+ "10",
+ "--version",
+ "2026.7.15",
+ "--current-version",
+ current_version,
+ ]
+
+
+def _restart_marker_command(install_root: Path, marker: Path, value: str) -> list[str]:
+ runtime_python = install_root / ".venv" / "bin" / "python"
+ script = f"from pathlib import Path; Path({str(marker)!r}).write_text({value!r}, encoding='utf-8')"
+ return [str(runtime_python), "-c", script]
+
+
+def _run_handoff(command: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ command,
+ cwd=REPO_ROOT,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=20,
+ check=False,
+ )
+
+
+def _wait_for_marker(marker: Path, expected: str) -> None:
+ deadline = time.monotonic() + 5
+ while time.monotonic() < deadline:
+ if marker.is_file() and marker.read_text(encoding="utf-8") == expected:
+ return
+ time.sleep(0.05)
+ raise AssertionError(f"restart marker was not written: {marker}")
+
+
+@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS)
+def test_v2026_7_1_real_handoff_command_restarts_with_current_code(
+ tmp_path: Path,
+ locale_args: list[str],
+) -> None:
+ install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path)
+ marker = tmp_path / "v2026.7.1-restarted"
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+ parent = _parent_process()
+ command = _base_handoff_command(
+ parent_pid=parent.pid,
+ install_root=install_root,
+ uv_path=fake_uv,
+ backend_port=_free_port(),
+ frontend_port=_free_port(),
+ current_version="2026.7.1",
+ )
+ install_index = command.index("--install-root")
+ command[install_index:install_index] = [
+ "--backend-pid-file",
+ str(tmp_path / "backend.pid"),
+ ]
+ command.extend(
+ [
+ *locale_args,
+ "--backup-path",
+ str(backup_path),
+ "--",
+ *_restart_marker_command(install_root, marker, "v2026.7.1"),
+ ]
+ )
+
+ completed = _run_handoff(command, env=env)
+ parent.wait(timeout=5)
+
+ assert completed.returncode == 0, completed.stderr
+ _wait_for_marker(marker, "v2026.7.1")
+
+
+@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS)
+def test_v2026_7_15_real_handoff_command_restarts_with_current_code(
+ tmp_path: Path,
+ locale_args: list[str],
+) -> None:
+ install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path)
+ marker = tmp_path / "v2026.7.15-restarted"
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+ public_port = _free_port()
+ parent = _parent_process()
+ command = _base_handoff_command(
+ parent_pid=parent.pid,
+ install_root=install_root,
+ uv_path=fake_uv,
+ backend_port=public_port,
+ frontend_port=public_port,
+ current_version="2026.7.15",
+ )
+ command.extend(
+ [
+ *locale_args,
+ "--backup-path",
+ str(backup_path),
+ "--prepare-handover",
+ "--",
+ *_restart_marker_command(install_root, marker, "v2026.7.15"),
+ ]
+ )
+
+ completed = _run_handoff(command, env=env)
+ parent.wait(timeout=5)
+
+ assert completed.returncode == 0, completed.stderr
+ _wait_for_marker(marker, "v2026.7.15")
+
+
+@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS)
+def test_current_real_upgrade_handoff_command_restarts_with_replaced_source(
+ tmp_path: Path,
+ locale_args: list[str],
+) -> None:
+ install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path)
+ marker = tmp_path / "current-restarted"
+ env["FLOCKS_RESTART_MARKER"] = str(marker)
+
+ content_root = tmp_path / "staged"
+ fake_cli = content_root / "flocks" / "cli"
+ fake_cli.mkdir(parents=True)
+ (content_root / "flocks" / "__init__.py").write_text("", encoding="utf-8")
+ shutil.copy2(install_root / "pyproject.toml", content_root / "pyproject.toml")
+ (fake_cli / "__init__.py").write_text("", encoding="utf-8")
+ (fake_cli / "main.py").write_text(
+ "import os\n"
+ "from pathlib import Path\n"
+ "Path(os.environ['FLOCKS_RESTART_MARKER']).write_text('current', encoding='utf-8')\n",
+ encoding="utf-8",
+ )
+ backup_path = tmp_path / "backup.tar.gz"
+ backup_path.write_text("backup", encoding="utf-8")
+
+ backend_port = _free_port()
+ frontend_port = _free_port()
+ config = service_manager.ServiceConfig(
+ backend_host="127.0.0.1",
+ backend_port=backend_port,
+ frontend_host="127.0.0.1",
+ frontend_port=frontend_port,
+ no_browser=True,
+ skip_frontend_build=True,
+ )
+ parent = _parent_process()
+ command = _base_handoff_command(
+ parent_pid=parent.pid,
+ install_root=install_root,
+ uv_path=fake_uv,
+ backend_port=backend_port,
+ frontend_port=frontend_port,
+ current_version="2026.7.15",
+ )
+ command.extend(
+ [
+ *locale_args,
+ "--mode",
+ "upgrade",
+ "--content-root",
+ str(content_root),
+ "--backup-path",
+ str(backup_path),
+ "--was-running",
+ "--service-config-json",
+ json.dumps(service_config_payload(config)),
+ "--",
+ str(install_root / ".venv" / "bin" / "python"),
+ ]
+ )
+
+ completed = _run_handoff(command, env=env)
+ parent.wait(timeout=5)
+
+ assert completed.returncode == 0, completed.stderr
+ assert marker.read_text(encoding="utf-8") == "current"
+ assert (install_root / "flocks" / "cli" / "main.py").is_file()
diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py
index cbeb4b043..58dec2b10 100644
--- a/tests/updater/test_updater.py
+++ b/tests/updater/test_updater.py
@@ -11,6 +11,7 @@
import pytest
from flocks.cli import service_control, service_manager
+from flocks.cli.service_config import service_config_payload
from flocks.updater import updater
from tests.helpers.service_supervisor import (
make_short_runtime_root,
@@ -73,6 +74,87 @@ def test_current_service_config_requires_supervisor_control_api(monkeypatch: pyt
updater._current_service_config()
+def test_capture_service_snapshot_preserves_complete_supervisor_config(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config = service_manager.ServiceConfig(
+ backend_host="2001:db8::20",
+ backend_port=9527,
+ frontend_host="2001:db8::20",
+ frontend_port=9527,
+ legacy_backend_host="0.0.0.0",
+ legacy_backend_port=9000,
+ server_port_migration_hint=True,
+ no_browser=False,
+ skip_frontend_build=False,
+ )
+ payload = {
+ "daemon": {"pid": 2468, "state": "running"},
+ "backend": {
+ "pid": 3579,
+ "host": config.backend_host,
+ "port": config.backend_port,
+ "state": "healthy",
+ "health": "healthy",
+ },
+ "webui": {
+ "host": config.frontend_host,
+ "port": config.frontend_port,
+ "state": "static",
+ },
+ "config": service_config_payload(config),
+ }
+ status = service_control.parse_supervisor_status(payload)
+ monkeypatch.setattr(service_control, "read_supervisor_status", lambda **_kwargs: status)
+
+ snapshot = updater._capture_service_snapshot()
+
+ assert snapshot.config == config
+ assert snapshot.daemon_pid == 2468
+ assert snapshot.was_running is True
+
+
+def test_spawn_restart_handoff_redirects_output_to_backend_log(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ capfd: pytest.CaptureFixture[str],
+) -> None:
+ flocks_root = tmp_path / "flocks-root"
+ command = [
+ sys.executable,
+ "-c",
+ "import sys; print('handoff stdout'); print('handoff stderr', file=sys.stderr)",
+ ]
+ monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root))
+
+ process = updater._spawn_restart_handoff(command, cwd=tmp_path)
+
+ assert process.wait(timeout=10) == 0
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+ backend_output = (flocks_root / "logs" / "backend.log").read_text(encoding="utf-8")
+ assert "handoff stdout" in backend_output
+ assert "handoff stderr" in backend_output
+
+
+def test_capture_service_snapshot_allows_stopped_service_without_control_api(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ service_control,
+ "read_supervisor_status",
+ lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("control down")),
+ )
+ monkeypatch.setattr(service_manager, "read_runtime_record", lambda _path: None)
+ monkeypatch.setattr(service_manager, "trusted_daemon_process_pids", lambda **_kwargs: [])
+
+ snapshot = updater._capture_service_snapshot()
+
+ assert snapshot.daemon_pid is None
+ assert snapshot.was_running is False
+
+
def test_run_handles_none_process_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def fake_run(*args, **kwargs):
return subprocess.CompletedProcess(args=args[0], returncode=0, stdout=None, stderr=None)
@@ -107,41 +189,6 @@ def fake_run(*args, **kwargs):
assert stderr == "failed�output"
-@pytest.mark.asyncio
-async def test_await_ignoring_cancellation_backs_off_on_repeated_cancellation(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- shield_calls = 0
- sleep_delays: list[float] = []
-
- async def fake_shield(task):
- nonlocal shield_calls
- shield_calls += 1
- if shield_calls <= 2:
- raise updater.asyncio.CancelledError
- return await task
-
- async def fake_sleep(delay: float) -> None:
- sleep_delays.append(delay)
- if len(sleep_delays) == 1:
- raise updater.asyncio.CancelledError
-
- async def critical_step() -> str:
- return "done"
-
- monkeypatch.setattr(updater.asyncio, "shield", fake_shield)
- monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
-
- result = await updater._await_ignoring_cancellation(critical_step())
-
- assert result == "done"
- assert shield_calls == 3
- assert sleep_delays == [
- updater._CANCELLATION_RETRY_DELAY_SECONDS,
- updater._CANCELLATION_RETRY_DELAY_SECONDS,
- ]
-
-
def test_get_current_version_prefers_higher_marker_version(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -516,7 +563,6 @@ def test_build_dependency_sync_command_installs_project_on_windows(
assert updater._build_dependency_sync_command("uv", uv_default_index="https://mirror.example/simple") == [
"uv",
"sync",
- "--frozen",
"--no-python-downloads",
"--default-index",
"https://mirror.example/simple",
@@ -528,7 +574,7 @@ def test_build_dependency_sync_command_keeps_project_install_on_non_windows(
) -> None:
monkeypatch.setattr(updater.sys, "platform", "linux")
- assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--frozen", "--no-python-downloads"]
+ assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--no-python-downloads"]
def test_wheel_build_config_does_not_force_include_runtime_or_build_outputs() -> None:
@@ -563,21 +609,6 @@ def test_build_frontend_subprocess_env_prepends_bundled_node_on_windows(
assert result["PATH"].split(os.pathsep)[0] == str(node_home)
-def test_upgrade_page_html_contains_marker_and_version() -> None:
- html = updater._upgrade_page_html("2026.3.31.1")
-
- assert "flocks-upgrade-in-progress" in html
- assert "v2026.3.31.1" in html
- assert "window.location.reload()" in html
-
-
-def test_upgrade_page_probe_urls_support_ipv6_loopback_fallback() -> None:
- assert updater._upgrade_page_probe_urls("::", 5173) == [
- "http://[::1]:5173",
- "http://127.0.0.1:5173",
- ]
-
-
def test_resolve_update_mirror_profile_uses_cn_defaults_for_zh_locale() -> None:
profile = updater._resolve_update_mirror_profile(
["github", "gitee", "gitlab"],
@@ -823,10 +854,9 @@ def test_build_restart_handoff_argv_rewrites_serve_to_managed_start(
sync_timeout=300,
version="2026.4.1",
current_version="2026.3.31",
- prepare_handover=True,
)
- assert "--prepare-handover" in argv[: argv.index("--")]
+ assert "--mode" not in argv
assert argv[argv.index("--") + 1 :] == [
"python",
"-m",
@@ -845,6 +875,29 @@ def test_build_restart_handoff_argv_rewrites_serve_to_managed_start(
]
+def test_build_restart_handoff_argv_can_skip_parent_wait(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ monkeypatch.setattr(
+ updater,
+ "_handoff_service_config",
+ lambda: service_manager.ServiceConfig(),
+ )
+
+ argv = updater._build_restart_handoff_argv(
+ ["python"],
+ tmp_path,
+ uv_path="uv",
+ sync_timeout=300,
+ version="2026.4.1",
+ current_version="2026.3.31",
+ wait_for_parent=False,
+ )
+
+ assert "--parent-pid" not in argv
+
+
def test_refresh_global_cli_entry_creates_symlink_on_unix(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -992,6 +1045,70 @@ async def fail_run_async(*_args, **_kwargs):
assert await updater._validate_restart_runtime(tmp_path) is None
+@pytest.mark.asyncio
+async def test_shared_installer_runs_core_steps_in_order(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ webui_dir = tmp_path / "webui"
+ webui_dir.mkdir()
+ (webui_dir / "package.json").write_text("{}", encoding="utf-8")
+ events: list[str] = []
+
+ async def fake_sync(**_kwargs) -> None:
+ events.append("sync")
+ return None
+
+ async def fake_build(*_args, **_kwargs) -> None:
+ events.append("build")
+ return None
+
+ async def fake_validate(_root: Path) -> None:
+ events.append("validate")
+ return None
+
+ monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "/usr/bin/npm")
+ monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True)
+ monkeypatch.setattr(updater, "_sync_project_dependencies", fake_sync)
+ monkeypatch.setattr(updater, "_build_frontend_workspace", fake_build)
+ monkeypatch.setattr(updater, "_validate_restart_runtime", fake_validate)
+ monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: events.append("refresh-cli"))
+ monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}"))
+
+ await updater.install_or_repair_source(
+ tmp_path,
+ version="v2026.7.2",
+ uv_path="/usr/bin/uv",
+ )
+
+ assert events == ["sync", "build", "validate", "refresh-cli", "marker:2026.7.2"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("platform", "script"),
+ [("darwin", "scripts/install.sh"), ("win32", "scripts/install.ps1")],
+)
+async def test_shared_installer_reports_missing_npm_with_platform_installer_hint(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ platform: str,
+ script: str,
+) -> None:
+ webui_dir = tmp_path / "webui"
+ webui_dir.mkdir()
+ (webui_dir / "package.json").write_text("{}", encoding="utf-8")
+ monkeypatch.setattr(updater.sys, "platform", platform)
+ monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: None)
+
+ with pytest.raises(RuntimeError, match=script):
+ await updater.install_or_repair_source(
+ tmp_path,
+ version="2026.7.2",
+ uv_path="uv",
+ )
+
+
def test_rmtree_onerror_retries_before_logging_skip(monkeypatch: pytest.MonkeyPatch) -> None:
attempts: list[str] = []
warnings: list[tuple[str, dict[str, str]]] = []
@@ -1063,495 +1180,6 @@ def test_safe_remove_renames_locked_directory_on_windows(
assert (leftovers[0] / "dist" / "index.html").exists()
-@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API")
-def test_prepare_upgrade_handover_writes_state_and_stops_frontend_with_real_control_api(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- short_root = make_short_runtime_root("flocks-updater-")
- monkeypatch.setenv("FLOCKS_ROOT", str(short_root))
- paths = service_manager.runtime_paths()
- config = service_manager.ServiceConfig(
- backend_host="127.0.0.1",
- backend_port=9995,
- frontend_host="127.0.0.1",
- frontend_port=9996,
- )
- daemon, thread = start_supervisor(config)
- wait_for_supervisor(paths, running=True)
-
- monkeypatch.setattr(
- updater,
- "_start_upgrade_page_server",
- lambda _config, _version: {
- "upgrade_server_pid": 321,
- "page_dir": str(short_root / "page"),
- "page_log": str(short_root / "logs" / "upgrade.log"),
- },
- )
-
- try:
- payload = updater._prepare_upgrade_handover("2026.3.31.1")
-
- status = service_control.read_supervisor_status(paths)
- assert status.backend.paused is True
- assert status.webui.paused is True
- assert payload["upgrade_server_pid"] == 321
- assert payload["backend_port"] == 9995
- assert payload["frontend_port"] == 9996
- assert updater._read_upgrade_state()["version"] == "2026.3.31.1"
- finally:
- stop_supervisor(daemon, thread)
- shutil.rmtree(short_root, ignore_errors=True)
-
-
-@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API")
-def test_prepare_upgrade_handover_restores_frontend_when_upgrade_page_fails_with_real_control_api(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- short_root = make_short_runtime_root("flocks-updater-")
- monkeypatch.setenv("FLOCKS_ROOT", str(short_root))
- paths = service_manager.runtime_paths()
- config = service_manager.ServiceConfig(
- backend_host="127.0.0.1",
- backend_port=9995,
- frontend_host="127.0.0.1",
- frontend_port=9996,
- )
- daemon, thread = start_supervisor(config)
- wait_for_supervisor(paths, running=True)
- calls: list[str] = []
-
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: calls.append("stop_page"))
- monkeypatch.setattr(
- updater,
- "_start_upgrade_page_server",
- lambda _config, _version: (_ for _ in ()).throw(RuntimeError("page failed")),
- )
-
- try:
- with pytest.raises(RuntimeError, match="page failed"):
- updater._prepare_upgrade_handover("2026.3.31.1")
-
- status = service_control.read_supervisor_status(paths)
- assert calls == ["stop_page"]
- assert status.backend.paused is False
- assert status.backend.pid is not None
- assert status.webui.paused is False
- assert status.webui.pid is None
- assert status.webui.state == "static"
- assert updater._read_upgrade_state() is None
- finally:
- stop_supervisor(daemon, thread)
- shutil.rmtree(short_root, ignore_errors=True)
-
-
-@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API")
-def test_rollback_failed_update_resumes_backend_when_handoff_tasks_fail(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- short_root = make_short_runtime_root("flocks-updater-")
- monkeypatch.setenv("FLOCKS_ROOT", str(short_root))
- paths = service_manager.runtime_paths()
- config = service_manager.ServiceConfig(
- backend_host="127.0.0.1",
- backend_port=9995,
- frontend_host="127.0.0.1",
- frontend_port=9996,
- )
- daemon, thread = start_supervisor(config)
- wait_for_supervisor(paths, running=True)
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: None)
-
- try:
- updater._write_upgrade_state(
- {
- "version": "2026.4.1",
- "backend_host": "127.0.0.1",
- "backend_port": 9995,
- "frontend_host": "127.0.0.1",
- "frontend_port": 9996,
- "skip_frontend_build": True,
- }
- )
- old_backend = daemon.backend.process
- assert old_backend is not None
- service_control.request_prepare_upgrade(paths=paths)
- wait_for_process_exit(old_backend)
-
- updater._rollback_failed_update(None, short_root / "install", "2026.3.31")
-
- status = service_control.read_supervisor_status(paths)
- assert status.backend.paused is False
- assert status.webui.paused is False
- assert status.backend.pid is not None
- assert status.backend.pid != old_backend.pid
- assert status.webui.pid is None
- assert status.webui.state == "static"
- assert updater._read_upgrade_state() is None
- finally:
- stop_supervisor(daemon, thread)
- shutil.rmtree(short_root, ignore_errors=True)
-
-
-def test_recover_upgrade_state_restarts_frontend_and_clears_marker(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks"))
- started: list[tuple[int, bool | None]] = []
- stopped: list[str] = []
-
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: stopped.append("stop"))
- monkeypatch.setattr(
- service_control,
- "request_resume_upgrade",
- lambda config, **_kwargs: started.append((config.frontend_port, config.skip_frontend_build))
- or _webui_control_status(),
- )
- updater._write_upgrade_state(
- {
- "version": "2026.3.31.1",
- "backend_host": "127.0.0.1",
- "backend_port": 8000,
- "frontend_host": "127.0.0.1",
- "frontend_port": 5173,
- "skip_frontend_build": True,
- }
- )
-
- updater.recover_upgrade_state()
-
- assert stopped == ["stop"]
- assert started == [(5173, True)]
- assert updater._read_upgrade_state() is None
-
-
-def test_recover_upgrade_state_retries_frontend_with_build_when_dist_is_missing(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks"))
- starts: list[tuple[str, bool | None, bool | None]] = []
-
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None)
-
- results = iter([
- _webui_control_payload("degraded", "missing dist"),
- _webui_control_payload(),
- ])
-
- def fake_resume_upgrade(config, **_kwargs):
- starts.append(("resume", config.skip_frontend_build, None))
- return service_control.parse_supervisor_status(next(results))
-
- def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs):
- starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None))
- return service_control.parse_supervisor_status(next(results))
-
- monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade)
- monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui)
- updater._write_upgrade_state(
- {
- "version": "2026.3.31.1",
- "backend_host": "127.0.0.1",
- "backend_port": 8000,
- "frontend_host": "127.0.0.1",
- "frontend_port": 5173,
- "skip_frontend_build": True,
- }
- )
-
- updater.recover_upgrade_state()
-
- assert starts == [("resume", True, None), ("restart_webui", False, True)]
- assert updater._read_upgrade_state() is None
-
-
-def test_recover_upgrade_state_restart_failure_clears_state_without_restarting_page(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks"))
- starts: list[tuple[str, bool | None, bool | None]] = []
-
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None)
-
- def fake_resume_upgrade(config, **_kwargs):
- starts.append(("resume", config.skip_frontend_build, None))
- return _webui_control_status("degraded", "still broken")
-
- def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs):
- starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None))
- return _webui_control_status("degraded", "still broken")
-
- monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade)
- monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui)
- updater._write_upgrade_state(
- {
- "version": "2026.3.31.1",
- "backend_host": "127.0.0.1",
- "backend_port": 8000,
- "frontend_host": "127.0.0.1",
- "frontend_port": 5173,
- "skip_frontend_build": True,
- }
- )
-
- with pytest.raises(RuntimeError, match="still broken"):
- updater.recover_upgrade_state()
-
- assert starts == [("resume", True, None), ("restart_webui", False, True)]
- assert updater._read_upgrade_state() is None
-
-
-def test_start_upgrade_page_server_binds_configured_frontend_host(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- flocks_root = tmp_path / ".flocks"
- (flocks_root / "run").mkdir(parents=True)
- monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root))
-
- page_dir = tmp_path / "page"
- page_dir.mkdir()
- captured: dict[str, object] = {}
-
- monkeypatch.setattr(updater, "_write_upgrade_page", lambda _version: page_dir)
- monkeypatch.setattr(updater, "_wait_for_upgrade_page", lambda config: captured.setdefault("wait_host", config.frontend_host))
- monkeypatch.setattr(
- service_manager,
- "resolve_python_subprocess_command",
- lambda _root=None: ["/env/bin/python"],
- )
- monkeypatch.setattr(
- updater,
- "_spawn_detached_process",
- lambda command, *, cwd, log_path: captured.update({
- "command": command,
- "cwd": cwd,
- "log_path": log_path,
- }) or SimpleNamespace(pid=4321),
- )
-
- config = service_manager.ServiceConfig(frontend_host="0.0.0.0", frontend_port=5173)
- payload = updater._start_upgrade_page_server(config, "2026.4.1")
-
- assert payload["upgrade_server_pid"] == 4321
- assert captured["command"] == [
- "/env/bin/python",
- "-m",
- "http.server",
- "5173",
- "--bind",
- "0.0.0.0",
- "--directory",
- str(page_dir.resolve()),
- ]
- assert captured["wait_host"] == "0.0.0.0"
-
-
-def test_stop_upgrade_page_server_does_not_kill_unified_flocks_service(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- flocks_root = tmp_path / ".flocks"
- monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root))
- killed: list[int] = []
-
- monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111])
- monkeypatch.setattr(
- service_manager,
- "_process_command_line",
- lambda _pid: "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173",
- )
- monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid))
-
- updater._stop_upgrade_page_server(frontend_port=5173)
-
- assert killed == []
-
-
-def test_stop_upgrade_page_server_kills_only_upgrade_page_process(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- flocks_root = tmp_path / ".flocks"
- page_dir = flocks_root / "run" / "upgrade-page"
- monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root))
- killed: list[int] = []
-
- def fake_command_line(pid: int) -> str:
- if pid == 222:
- return f"/env/bin/python -m http.server 5173 --directory {page_dir}"
- return "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173"
-
- monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111, 222])
- monkeypatch.setattr(service_manager, "_process_command_line", fake_command_line)
- monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid))
- monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None)
-
- updater._stop_upgrade_page_server(frontend_port=5173)
-
- assert killed == [222]
-
-
-def test_wait_for_upgrade_page_uses_access_host_for_local_probe(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- requested_urls: list[str] = []
-
- class _FakeClient:
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc, tb):
- return False
-
- def get(self, url):
- requested_urls.append(url)
- return SimpleNamespace(status_code=200)
-
- monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient())
- monkeypatch.setattr(service_manager, "access_host", lambda host: "127.0.0.1" if host == "0.0.0.0" else host)
- monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None)
-
- updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="0.0.0.0", frontend_port=5173))
-
- assert requested_urls == ["http://127.0.0.1:5173"]
-
-
-def test_wait_for_upgrade_page_falls_back_from_ipv6_to_ipv4_probe(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- requested_urls: list[str] = []
-
- class _FakeClient:
- def __enter__(self):
- return self
-
- def __exit__(self, exc_type, exc, tb):
- return False
-
- def get(self, url):
- requested_urls.append(url)
- if url == "http://[::1]:5173":
- raise OSError("ipv6 unavailable")
- return SimpleNamespace(status_code=200)
-
- monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient())
- monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None)
-
- updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="::", frontend_port=5173))
-
- assert requested_urls == [
- "http://[::1]:5173",
- "http://127.0.0.1:5173",
- ]
-
-
-def test_rollback_failed_update_restores_backup_and_rebuilds_frontend_if_needed(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks"))
- events: list[str] = []
-
- monkeypatch.setattr(updater, "_restore_backup_archive", lambda backup, root: events.append(f"restore:{backup.name}:{root.name}"))
- monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}"))
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page"))
- monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}"))
-
- results = iter([
- _webui_control_payload("degraded", "missing dist"),
- _webui_control_payload(),
- ])
-
- def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus:
- events.append(f"resume:{config.skip_frontend_build}")
- return service_control.parse_supervisor_status(next(results))
-
- def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs) -> service_control.SupervisorStatus:
- events.append(f"restart_webui:{config.skip_frontend_build}:{force_frontend_build or None}")
- return service_control.parse_supervisor_status(next(results))
-
- monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade)
- monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui)
- updater._write_upgrade_state(
- {
- "version": "2026.4.1",
- "backend_host": "127.0.0.1",
- "backend_port": 8000,
- "frontend_host": "127.0.0.1",
- "frontend_port": 5173,
- "skip_frontend_build": True,
- }
- )
-
- backup_path = tmp_path / "backup.tar.gz"
- backup_path.write_text("backup", encoding="utf-8")
- updater._rollback_failed_update(backup_path, tmp_path / "install", "2026.3.31")
-
- assert events == [
- "restore:backup.tar.gz:install",
- "marker:2026.3.31",
- "stop_page",
- "resume:True",
- "restart_webui:False:True",
- "rmtree:upgrade-page",
- ]
- assert updater._read_upgrade_state() is None
-
-
-def test_rollback_failed_update_clears_state_when_restore_and_frontend_both_fail(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks"))
- events: list[str] = []
-
- monkeypatch.setattr(
- updater,
- "_restore_backup_archive",
- lambda *_args: (_ for _ in ()).throw(RuntimeError("backup broken")),
- )
- monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}"))
- monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page"))
- monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}"))
-
- def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus:
- events.append(f"resume:{config.skip_frontend_build}")
- return _webui_control_status("degraded", "frontend still broken")
-
- def fake_restart_webui(config, **_kwargs) -> service_control.SupervisorStatus:
- events.append(f"restart_webui:{config.skip_frontend_build}")
- return _webui_control_status("degraded", "frontend still broken")
-
- monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade)
- monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui)
- updater._write_upgrade_state(
- {
- "version": "2026.4.1",
- "backend_host": "127.0.0.1",
- "backend_port": 8000,
- "frontend_host": "127.0.0.1",
- "frontend_port": 5173,
- "skip_frontend_build": True,
- "phase": "cutover_applied",
- }
- )
-
- backup_path = tmp_path / "backup.tar.gz"
- backup_path.write_text("backup", encoding="utf-8")
- updater._rollback_failed_update(backup_path, tmp_path / "install", "2026.3.31")
-
- assert events == [
- "stop_page",
- "resume:True",
- "rmtree:upgrade-page",
- ]
- assert updater._read_upgrade_state() is None
-
-
def test_backup_current_version_excludes_all_dist_directories(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -1566,6 +1194,12 @@ def test_backup_current_version_excludes_all_dist_directories(
(webui_dist / "index.html").write_text("ok", encoding="utf-8")
(git_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
(install_root / "flocks.json").write_text('{"keep": true}', encoding="utf-8")
+ runtime_data = install_root / "data"
+ runtime_data.mkdir()
+ (runtime_data / "flocks.db").write_text("runtime", encoding="utf-8")
+ nested_source_config = install_root / "webui" / "src" / "locales" / "en-US" / "config.json"
+ nested_source_config.parent.mkdir(parents=True)
+ nested_source_config.write_text('{"source": true}', encoding="utf-8")
(other_dist / "ignored.txt").write_text("nope", encoding="utf-8")
backup_dir = tmp_path / "backups"
@@ -1577,7 +1211,9 @@ def test_backup_current_version_excludes_all_dist_directories(
names = tar.getnames()
assert "flocks/webui/dist/index.html" not in names
- assert "flocks/flocks.json" in names
+ assert "flocks/flocks.json" not in names
+ assert "flocks/data/flocks.db" not in names
+ assert "flocks/webui/src/locales/en-US/config.json" in names
assert "flocks/.git/HEAD" not in names
assert "flocks/dist/ignored.txt" not in names
@@ -1689,6 +1325,8 @@ def test_replace_install_dir_copies_dot_flocks_plugins_from_source(
(source_dir / "flocks.json").write_text('{"version": "new"}', encoding="utf-8")
(install_root / "flocks.json").write_text('{"keep": true}', encoding="utf-8")
+ (install_root / "run").mkdir()
+ (install_root / "run" / "service.pid").write_text("2468", encoding="utf-8")
(install_root / ".git").mkdir()
(install_root / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
@@ -1697,14 +1335,37 @@ def test_replace_install_dir_copies_dot_flocks_plugins_from_source(
assert (inst_plugins / "fofa" / "_provider.yaml").read_text(encoding="utf-8") == "version: new"
assert (inst_plugins / "new_release_plugin" / "tool.yaml").read_text(encoding="utf-8") == "name: new"
assert not (inst_plugins / "obsolete_plugin").exists()
- assert (install_root / "flocks.json").read_text(encoding="utf-8") == '{"version": "new"}'
+ assert (install_root / "flocks.json").read_text(encoding="utf-8") == '{"keep": true}'
+ assert (install_root / "run" / "service.pid").read_text(encoding="utf-8") == "2468"
assert (install_root / ".git" / "HEAD").read_text(encoding="utf-8") == "ref: refs/heads/main"
@pytest.mark.asyncio
-async def test_perform_update_schedules_handoff_with_deferred_handover(
+@pytest.mark.parametrize(
+ ("locale", "expected_sources", "expected_mirror_args", "wait_for_handoff"),
+ [
+ pytest.param("en-US", ["github", "gitee"], [], False, id="english-detached-upgrade"),
+ pytest.param(
+ "zh-CN",
+ ["gitee", "github"],
+ [
+ "--uv-default-index",
+ "https://mirrors.aliyun.com/pypi/simple",
+ "--npm-registry",
+ "https://registry.npmmirror.com/",
+ ],
+ True,
+ id="chinese-waited-upgrade",
+ ),
+ ],
+)
+async def test_perform_update_only_stages_source_and_schedules_upgrade_handoff(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+ locale: str,
+ expected_sources: list[str],
+ expected_mirror_args: list[str],
+ wait_for_handoff: bool,
) -> None:
archive_path = tmp_path / "flocks.zip"
archive_path.write_text("archive", encoding="utf-8")
@@ -1720,11 +1381,13 @@ async def test_perform_update_schedules_handoff_with_deferred_handover(
events: list[str] = []
popen_calls: list[list[str]] = []
+ download_sources: list[str] = []
+ progress_stages: list[str] = []
async def fake_get_updater_config():
return SimpleNamespace(
archive_format="zip",
- sources=["github"],
+ sources=["github", "gitee"],
repo="AgentFlocks/Flocks",
token=None,
gitee_token=None,
@@ -1733,7 +1396,8 @@ async def fake_get_updater_config():
gitee_repo=None,
)
- async def fake_download_with_fallback(**_kwargs):
+ async def fake_download_with_fallback(**kwargs):
+ download_sources.extend(kwargs["sources"])
return archive_path
async def fake_sleep(_seconds) -> None:
@@ -1747,15 +1411,30 @@ async def fake_sleep(_seconds) -> None:
monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root)
monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31")
monkeypatch.setattr(updater, "_download_with_fallback", fake_download_with_fallback)
- monkeypatch.setattr(updater, "_backup_current_version", lambda *_args, **_kwargs: tmp_path / "backup.tar.gz")
+ monkeypatch.setattr(
+ updater,
+ "_backup_current_version",
+ lambda *_args, **_kwargs: events.append("backup") or tmp_path / "backup.tar.gz",
+ )
monkeypatch.setattr(updater, "_extract_archive", lambda *_args, **_kwargs: staged_root)
monkeypatch.setattr(
updater,
"_find_executable",
lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv",
)
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover") or {})
- monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig())
+ config = service_manager.ServiceConfig(
+ backend_host="10.20.30.40",
+ backend_port=9527,
+ frontend_host="10.20.30.40",
+ frontend_port=9527,
+ legacy_backend_host="0.0.0.0",
+ legacy_backend_port=9000,
+ )
+ monkeypatch.setattr(
+ updater,
+ "_capture_service_snapshot",
+ lambda: updater.ServiceSnapshot(config=config, daemon_pid=2468, was_running=True),
+ )
monkeypatch.setattr(
updater,
"_replace_install_dir",
@@ -1764,43 +1443,111 @@ async def fake_sleep(_seconds) -> None:
)
monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: ["/usr/bin/python3", "-m", "flocks.cli.main", "start"])
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
- monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback"))
- monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda *_args: events.append("rollback_handover"))
monkeypatch.setattr(
updater,
"_spawn_restart_handoff",
- lambda argv, **_kwargs: popen_calls.append(list(argv)) or SimpleNamespace(pid=4321),
+ lambda argv, **_kwargs: popen_calls.append(list(argv))
+ or SimpleNamespace(pid=4321, wait=lambda: events.append("wait") or 0),
)
monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code)))
- with pytest.raises(SystemExit, match="0"):
- async for _step in updater.perform_update("2026.4.1"):
- pass
-
- assert events[:2] == ["replace", "sleep"]
- assert "handover" not in events
+ if wait_for_handoff:
+ async for step in updater.perform_update(
+ "2026.4.1",
+ locale=locale,
+ wait_for_handoff=True,
+ ):
+ progress_stages.append(step.stage)
+ else:
+ with pytest.raises(SystemExit, match="0"):
+ async for step in updater.perform_update("2026.4.1", locale=locale):
+ progress_stages.append(step.stage)
+
+ expected_events = ["backup", "sleep"]
+ expected_stages = ["fetching", "backing_up", "applying", "restarting"]
+ if wait_for_handoff:
+ expected_events.append("wait")
+ expected_stages.append("done")
+
+ assert events == expected_events
+ assert progress_stages == expected_stages
+ assert download_sources == expected_sources
assert len(popen_calls) == 1
handoff_argv = popen_calls[0]
assert handoff_argv[:3] == ["/usr/bin/python3", "-m", "flocks.updater.restart_handoff"]
assert "--uv-path" in handoff_argv
assert "--version" in handoff_argv
- assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")]
- assert handoff_argv[handoff_argv.index("--") + 1 :] == [
- "/usr/bin/python3",
- "-m",
- "flocks.cli.main",
- "start",
- "--no-browser",
- "--skip-webui-build",
- "--host",
- "127.0.0.1",
- "--port",
- "5173",
- "--server-host",
- "127.0.0.1",
- "--server-port",
- "8000",
- ]
+ assert "--mode" in handoff_argv
+ assert handoff_argv[handoff_argv.index("--mode") + 1] == "upgrade"
+ assert handoff_argv[handoff_argv.index("--content-root") + 1] == str(staged_root)
+ assert handoff_argv[handoff_argv.index("--backup-path") + 1] == str(tmp_path / "backup.tar.gz")
+ assert handoff_argv[handoff_argv.index("--daemon-pid") + 1] == "2468"
+ assert "--was-running" in handoff_argv
+ assert "--prepare-handover" not in handoff_argv
+ assert ("--parent-pid" in handoff_argv) is not wait_for_handoff
+ if expected_mirror_args:
+ mirror_index = handoff_argv.index("--uv-default-index")
+ assert handoff_argv[mirror_index : mirror_index + len(expected_mirror_args)] == expected_mirror_args
+ else:
+ assert "--uv-default-index" not in handoff_argv
+ assert "--npm-registry" not in handoff_argv
+ assert handoff_argv[handoff_argv.index("--") + 1 :] == ["/usr/bin/python3"]
+
+
+@pytest.mark.asyncio
+async def test_perform_update_aborts_before_handoff_when_backup_raises(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ archive_path = tmp_path / "flocks.zip"
+ archive_path.write_text("archive", encoding="utf-8")
+ update_dir = tmp_path / "update"
+ update_dir.mkdir()
+ staged_root = update_dir / "staged"
+ staged_root.mkdir()
+ install_root = tmp_path / "install-root"
+ install_root.mkdir()
+ handoff_spawned = False
+
+ async def fake_get_updater_config():
+ return SimpleNamespace(
+ archive_format="zip",
+ sources=["github"],
+ repo="AgentFlocks/Flocks",
+ token=None,
+ gitee_token=None,
+ backup_retain_count=3,
+ base_url=None,
+ gitee_repo=None,
+ )
+
+ async def fake_download_with_fallback(**_kwargs):
+ return archive_path
+
+ def fail_backup(*_args, **_kwargs):
+ raise OSError("backup directory is read-only")
+
+ def record_handoff(*_args, **_kwargs):
+ nonlocal handoff_spawned
+ handoff_spawned = True
+
+ monkeypatch.setattr(updater, "_get_updater_config", fake_get_updater_config)
+ monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root)
+ monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31")
+ monkeypatch.setattr(updater.tempfile, "mkdtemp", lambda **_kwargs: str(update_dir))
+ monkeypatch.setattr(updater, "_download_with_fallback", fake_download_with_fallback)
+ monkeypatch.setattr(updater, "_extract_archive", lambda *_args, **_kwargs: staged_root)
+ monkeypatch.setattr(updater, "_find_executable", lambda _name: "/usr/bin/uv")
+ monkeypatch.setattr(updater, "_backup_current_version", fail_backup)
+ monkeypatch.setattr(updater, "_spawn_restart_handoff", record_handoff)
+ monkeypatch.setattr(updater, "_record_update_journal", lambda _message: None)
+
+ progresses = [step async for step in updater.perform_update("2026.4.1")]
+
+ assert progresses[-1].stage == "error"
+ assert progresses[-1].message == "Failed to back up the current source; the update was not applied."
+ assert handoff_spawned is False
+ assert not update_dir.exists()
@pytest.mark.asyncio
@@ -1873,10 +1620,13 @@ async def fake_sleep(_seconds) -> None:
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(
updater,
- "_prepare_upgrade_handover",
- lambda _version: (_ for _ in ()).throw(RuntimeError("handover boom")),
+ "_capture_service_snapshot",
+ lambda: updater.ServiceSnapshot(
+ config=service_manager.ServiceConfig(),
+ daemon_pid=None,
+ was_running=False,
+ ),
)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
monkeypatch.setattr(
updater,
"_spawn_restart_handoff",
@@ -1888,13 +1638,14 @@ async def fake_sleep(_seconds) -> None:
async for _step in updater.perform_update("2026.4.1"):
pass
- assert events == ["replace"]
+ assert events == []
assert len(popen_calls) == 1
- assert "--prepare-handover" in popen_calls[0][: popen_calls[0].index("--")]
+ assert "--mode" in popen_calls[0]
+ assert "--prepare-handover" not in popen_calls[0]
@pytest.mark.asyncio
-async def test_perform_update_uses_cn_mirror_profile_for_sources_and_dependency_commands(
+async def test_perform_update_rejects_source_update_without_handoff(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -1958,25 +1709,14 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
)
]
- assert progresses[-1].stage == "done"
+ assert progresses[-1].stage == "error"
+ assert "detached handoff" in progresses[-1].message
assert captured["sources"] == ["gitee", "github"]
- assert run_calls == [
- (
- [
- "/usr/bin/uv",
- "sync",
- "--frozen",
- "--no-python-downloads",
- "--default-index",
- "https://mirrors.aliyun.com/pypi/simple",
- ],
- None,
- ),
- ]
+ assert run_calls == []
@pytest.mark.asyncio
-async def test_perform_update_retries_cn_uv_sync_with_default_source(
+async def test_dependency_sync_retries_cn_mirror_with_default_source(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2005,7 +1745,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
if cmd == [
"/usr/bin/uv",
"sync",
- "--frozen",
"--no-python-downloads",
"--default-index",
"https://mirrors.aliyun.com/pypi/simple",
@@ -2032,24 +1771,74 @@ async def fake_sleep(_seconds) -> None:
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")]
+ error = await updater._sync_project_dependencies(
+ uv_path="/usr/bin/uv",
+ install_root=install_root,
+ uv_default_index="https://mirrors.aliyun.com/pypi/simple",
+ env=None,
+ )
- assert progresses[-1].stage == "done"
+ assert error is None
assert run_calls == [
[
"/usr/bin/uv",
"sync",
- "--frozen",
"--no-python-downloads",
"--default-index",
"https://mirrors.aliyun.com/pypi/simple",
],
- ["/usr/bin/uv", "sync", "--frozen", "--no-python-downloads"],
+ ["/usr/bin/uv", "sync", "--no-python-downloads"],
]
@pytest.mark.asyncio
-async def test_perform_update_prefers_bundled_npm_for_windows_frontend_rebuild(
+async def test_dependency_sync_retries_default_source_after_timeout_and_logs_output(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ calls: list[list[str]] = []
+ warnings: list[tuple[str, dict[str, object]]] = []
+
+ async def fake_run_async(cmd, **_kwargs):
+ calls.append(list(cmd))
+ if "--default-index" in cmd:
+ raise subprocess.TimeoutExpired(
+ cmd=cmd,
+ timeout=300,
+ output=b"mirror stdout",
+ stderr=b"mirror stderr",
+ )
+ return 0, "", ""
+
+ monkeypatch.setattr(updater, "_run_async", fake_run_async)
+ monkeypatch.setattr(updater.log, "warning", lambda event, payload: warnings.append((event, payload)))
+
+ error = await updater._sync_project_dependencies(
+ uv_path="uv",
+ install_root=tmp_path,
+ uv_default_index="https://mirrors.aliyun.com/pypi/simple",
+ sync_timeout=300,
+ )
+
+ assert error is None
+ assert calls == [
+ [
+ "uv",
+ "sync",
+ "--no-python-downloads",
+ "--default-index",
+ "https://mirrors.aliyun.com/pypi/simple",
+ ],
+ ["uv", "sync", "--no-python-downloads"],
+ ]
+ timeout_payload = next(payload for event, payload in warnings if event == "updater.dependencies.sync_timeout")
+ assert timeout_payload["stdout"] == "mirror stdout"
+ assert timeout_payload["stderr"] == "mirror stderr"
+ assert timeout_payload["retry_without_default_index"] is True
+
+
+@pytest.mark.asyncio
+async def test_frontend_build_prefers_bundled_npm_on_windows(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2112,9 +1901,13 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False)
monkeypatch.setenv("PATH", "/usr/bin:/bin")
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")]
+ shutil.copytree(staged_webui, install_root / "webui")
+ frontend_error = await updater._build_frontend_workspace(
+ install_root / "webui",
+ npm_registry="https://registry.npmmirror.com/",
+ )
- assert progresses[-1].stage == "done"
+ assert frontend_error is None
frontend_calls = [
call for call in run_calls if call[0][0] == str(bundled_npm)
]
@@ -2133,7 +1926,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
@pytest.mark.asyncio
-async def test_perform_update_retries_windows_frontend_with_system_npm_after_bundled_build_failure(
+async def test_frontend_build_retries_system_npm_after_bundled_build_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2224,9 +2017,13 @@ def fake_find(name: str) -> str | None:
monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False)
monkeypatch.setenv("PATH", "/usr/bin:/bin")
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")]
+ shutil.copytree(staged_webui, install_webui)
+ frontend_error = await updater._build_frontend_workspace(
+ install_webui,
+ npm_registry="https://registry.npmmirror.com/",
+ )
- assert progresses[-1].stage == "done"
+ assert frontend_error is None
frontend_calls = [
call for call in run_calls if call[0][0] in {str(bundled_npm), system_npm}
]
@@ -2364,7 +2161,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
@pytest.mark.asyncio
-async def test_perform_update_retries_windows_frontend_with_full_timeout_after_bundled_install_and_ci_timeout(
+async def test_frontend_build_retries_system_npm_after_bundled_timeouts(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2454,9 +2251,13 @@ def fake_find(name: str) -> str | None:
monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False)
monkeypatch.setenv("PATH", "/usr/bin:/bin")
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")]
+ shutil.copytree(staged_webui, install_webui)
+ frontend_error = await updater._build_frontend_workspace(
+ install_webui,
+ npm_registry="https://registry.npmmirror.com/",
+ )
- assert progresses[-1].stage == "done"
+ assert frontend_error is None
frontend_calls = [
call for call in run_calls if call[0][0] in {str(bundled_npm), system_npm}
]
@@ -2520,7 +2321,7 @@ async def fake_download_with_fallback(**_kwargs):
@pytest.mark.asyncio
-async def test_perform_update_retries_uv_sync_on_first_failure(
+async def test_dependency_sync_retries_first_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2574,16 +2375,18 @@ async def fake_sleep(_s):
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
- progresses = [
- step async for step in updater.perform_update("2026.4.1", restart=False)
- ]
+ error = await updater._sync_project_dependencies(
+ uv_path="/usr/bin/uv",
+ install_root=install_root,
+ env=None,
+ )
- assert progresses[-1].stage == "done"
+ assert error is None
assert call_count == 2
@pytest.mark.asyncio
-async def test_perform_update_syncs_windows_venv_in_place(
+async def test_dependency_sync_updates_windows_venv_in_place(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2633,16 +2436,20 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None)
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
+ error = await updater._sync_project_dependencies(
+ uv_path=r"C:\tools\uv.exe",
+ install_root=install_root,
+ env=None,
+ )
- assert progresses[-1].stage == "done"
- assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--frozen", "--no-python-downloads"], install_root)]
+ assert error is None
+ assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--no-python-downloads"], install_root)]
assert (install_root / ".venv" / "Scripts" / "python.exe").read_text(encoding="utf-8") == "old"
assert not (install_root / ".venv.flocks_backup").exists()
@pytest.mark.asyncio
-async def test_perform_update_rolls_back_when_windows_uv_sync_times_out(
+async def test_dependency_sync_reports_windows_timeout_without_rollback(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2699,31 +2506,32 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
)
monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None)
monkeypatch.setattr(updater, "_replace_install_dir", lambda *_a, **_kw: None)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_a: events.append("restore"))
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
+ error = await updater._sync_project_dependencies(
+ uv_path=r"C:\tools\uv.exe",
+ install_root=install_root,
+ env=None,
+ )
- assert progresses[-1].stage == "error"
expected_timeout = updater._dependency_sync_timeout_seconds()
- assert progresses[-1].message == (
+ assert error == (
f"Dependency sync timed out after {expected_timeout}s while running uv sync."
)
- assert events == ["restore"]
+ assert events == []
assert (install_root / ".venv" / "Scripts" / "python.exe").read_text(encoding="utf-8") == "new"
assert not (install_root / ".venv.flocks_failed").exists()
assert not (install_root / ".venv.flocks_backup").exists()
@pytest.mark.asyncio
-async def test_perform_update_fails_after_uv_sync_retry_exhausted(
+async def test_dependency_sync_fails_after_retry_exhausted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
- """When uv sync fails twice, the updater should give up and rollback."""
+ """When uv sync fails twice, the updater should return the final error."""
archive_path = tmp_path / "flocks.tar.gz"
archive_path.write_text("archive", encoding="utf-8")
staged_root = tmp_path / "staged"
- rolled_back = False
async def fake_get_updater_config():
return SimpleNamespace(
@@ -2745,10 +2553,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
async def fake_download(**_kw):
return archive_path
- def fake_restore(*_args):
- nonlocal rolled_back
- rolled_back = True
-
monkeypatch.setattr(updater, "_get_updater_config", fake_get_updater_config)
monkeypatch.setattr(updater, "_get_repo_root", lambda: tmp_path / "install-root")
monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31")
@@ -2758,25 +2562,25 @@ def fake_restore(*_args):
monkeypatch.setattr(updater, "_run_async", fake_run_async)
monkeypatch.setattr(updater, "_find_executable", lambda _name: "/usr/bin/uv")
monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None)
+
async def fake_sleep(_s):
pass
monkeypatch.setattr(updater, "_replace_install_dir", lambda *_a, **_kw: None)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", fake_restore)
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
- progresses = [
- step async for step in updater.perform_update("2026.4.1", restart=False)
- ]
+ error = await updater._sync_project_dependencies(
+ uv_path="/usr/bin/uv",
+ install_root=tmp_path / "install-root",
+ env=None,
+ )
- error_events = [p for p in progresses if p.stage == "error"]
- assert len(error_events) == 1
- assert "resolution failed" in error_events[0].message
- assert rolled_back
+ assert error is not None
+ assert "resolution failed" in error
@pytest.mark.asyncio
-async def test_perform_update_repairs_broken_uv_managed_python_cache_before_retrying_sync(
+async def test_dependency_sync_repairs_broken_uv_managed_python_cache_before_retry(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2841,18 +2645,20 @@ async def fake_sleep(seconds):
monkeypatch.setattr(updater, "_repair_windows_uv_managed_python_install", lambda text: repaired_messages.append(text) or Path(r"C:\Users\worker\AppData\Roaming\uv\python\cpython-3.12-windows-x86_64-none"))
monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
- progresses = [
- step async for step in updater.perform_update("2026.4.1", restart=False)
- ]
+ error = await updater._sync_project_dependencies(
+ uv_path=r"C:\Users\worker\AppData\Local\Programs\Flocks\tools\uv\uv.exe",
+ install_root=install_root,
+ env=None,
+ )
- assert progresses[-1].stage == "done"
+ assert error is None
assert call_count == 2
assert sleep_calls == [2]
assert len(repaired_messages) == 1
@pytest.mark.asyncio
-async def test_perform_update_rolls_back_when_replace_fails_on_windows_locked_file(
+async def test_perform_update_without_handoff_never_attempts_source_replace(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2903,7 +2709,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
"_find_executable",
lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv",
)
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover") or {})
monkeypatch.setattr(
updater,
"_replace_install_dir",
@@ -2913,18 +2718,16 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
)
),
)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
-
- progresses = [step async for step in updater.perform_update("2026.4.1")]
+ progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
assert progresses[-1].stage == "error"
- assert "WinError 5" in progresses[-1].message
- assert events == ["restore"]
+ assert "detached handoff" in progresses[-1].message
+ assert events == []
assert "handover" not in events
@pytest.mark.asyncio
-async def test_perform_update_reports_windows_file_lock_without_stopping_current_backend(
+async def test_perform_update_without_handoff_does_not_touch_locked_windows_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -2983,11 +2786,16 @@ def fake_replace_install_dir(*_args, **_kwargs):
"_find_executable",
lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv",
)
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover"))
- monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig())
+ monkeypatch.setattr(
+ updater,
+ "_capture_service_snapshot",
+ lambda: updater.ServiceSnapshot(
+ config=service_manager.ServiceConfig(),
+ daemon_pid=None,
+ was_running=False,
+ ),
+ )
monkeypatch.setattr(updater, "_replace_install_dir", fake_replace_install_dir)
- monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback"))
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"])
monkeypatch.setattr(
updater,
@@ -2996,17 +2804,18 @@ def fake_replace_install_dir(*_args, **_kwargs):
)
monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code)))
- progresses = [step async for step in updater.perform_update("2026.4.1")]
+ progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
assert progresses[-1].stage == "error"
- assert "WinError 32" in progresses[-1].message
- assert events == ["replace-1", "restore"]
+ assert "detached handoff" in progresses[-1].message
+ assert replace_attempts["count"] == 0
+ assert events == []
assert "handover" not in events
assert "popen" not in events
@pytest.mark.asyncio
-async def test_perform_update_reports_frontend_dependency_install_timeout(
+async def test_frontend_workspace_reports_dependency_install_timeout(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -3064,23 +2873,19 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
or shutil.copytree(staged_webui, install_webui, dirs_exist_ok=True),
)
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
+ shutil.copytree(staged_webui, install_webui)
+ frontend_error = await updater._build_frontend_workspace(install_webui)
- assert progresses[-1].stage == "error"
- assert progresses[-1].message == "Frontend dependency install timed out after 300s while running npm ci."
+ assert frontend_error == "Frontend dependency install timed out after 300s while running npm ci."
assert events == [
- "replace",
- "/usr/bin/uv sync --frozen --no-python-downloads",
"/usr/bin/npm install",
"/usr/bin/npm ci",
- "restore",
]
@pytest.mark.asyncio
-async def test_perform_update_rolls_back_handover_when_current_frontend_build_fails(
+async def test_frontend_workspace_reports_build_failure_without_rollback(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -3144,13 +2949,12 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
lambda *_args, **_kwargs: events.append("replace")
or shutil.copytree(staged_webui, install_webui, dirs_exist_ok=True),
)
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
+ shutil.copytree(staged_webui, install_webui)
+ frontend_error = await updater._build_frontend_workspace(install_webui)
- assert progresses[-1].stage == "error"
- assert progresses[-1].message == "Frontend build failed: boom"
- assert events == ["replace", "uv-sync", "npm-install", "npm-build", "restore"]
+ assert frontend_error == "Frontend build failed: boom"
+ assert events == ["npm-install", "npm-build"]
@pytest.mark.asyncio
@@ -3204,7 +3008,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
"_find_executable",
lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv",
)
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover"))
monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None)
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
@@ -3215,7 +3018,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
await gen.aclose()
assert "handover" not in events
- assert updater._read_upgrade_state() is None
+ assert not updater._upgrade_result_path().exists()
@pytest.mark.asyncio
@@ -3271,7 +3074,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None)
monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"])
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover"))
monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig())
monkeypatch.setattr(
updater,
@@ -3291,29 +3093,15 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
assert handoff_argv[:3] == [r"C:\tool\python.exe", "-m", "flocks.updater.restart_handoff"]
assert "--parent-pid" in handoff_argv
assert "--backend-port" in handoff_argv
- assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")]
- assert handoff_argv[handoff_argv.index("--") + 1 :] == [
- r"C:\tool\python.exe",
- "-m",
- "flocks.cli.main",
- "start",
- "--no-browser",
- "--skip-webui-build",
- "--host",
- "127.0.0.1",
- "--port",
- "5173",
- "--server-host",
- "127.0.0.1",
- "--server-port",
- "8000",
- ]
+ assert handoff_argv[handoff_argv.index("--mode") + 1] == "upgrade"
+ assert "--prepare-handover" not in handoff_argv
+ assert handoff_argv[handoff_argv.index("--") + 1 :] == [r"C:\tool\python.exe"]
assert events == []
assert "execv" not in events
@pytest.mark.asyncio
-async def test_perform_update_stops_when_windows_restart_runtime_validation_fails(
+async def test_validate_restart_runtime_reports_missing_windows_python(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -3364,15 +3152,13 @@ async def fake_validate_restart_runtime(_install_root: Path) -> str | None:
)
monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None)
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: events.append("marker"))
- monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore"))
monkeypatch.setattr(updater, "_validate_restart_runtime", fake_validate_restart_runtime)
monkeypatch.setattr(updater.subprocess, "Popen", lambda *_args, **_kwargs: events.append("popen"))
- progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)]
+ validation_error = await updater._validate_restart_runtime(tmp_path / "install-root")
- assert progresses[-1].stage == "error"
- assert progresses[-1].message == "Restart runtime is missing: .venv/Scripts/python.exe"
- assert events == ["restore"]
+ assert validation_error == "Restart runtime is missing: .venv/Scripts/python.exe"
+ assert events == []
@pytest.mark.asyncio
@@ -3426,12 +3212,12 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
FileNotFoundError("python.exe not found"),
),
)
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover"))
progresses = [step async for step in updater.perform_update("2026.4.1")]
assert progresses[-1].stage == "error"
- assert "Failed to build restart command" in progresses[-1].message
+ assert "restarting" not in [step.stage for step in progresses]
+ assert "Failed to prepare restart handoff" in progresses[-1].message
assert "handover" not in events
@@ -3488,9 +3274,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None):
monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None)
monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None)
monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main"])
- monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover"))
monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig())
- monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda: events.append("rollback_handover"))
monkeypatch.setattr(
updater,
"_spawn_restart_handoff",
diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py
index 118c75f55..c43ed1c58 100644
--- a/tests/updater/test_updater_console_manifest_bundle.py
+++ b/tests/updater/test_updater_console_manifest_bundle.py
@@ -1,7 +1,8 @@
from __future__ import annotations
-import zipfile
import json
+import sys
+import zipfile
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@@ -527,6 +528,7 @@ async def _after_uninstall():
monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True)
monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(updater, "_uninstall_pro_component", _fake_uninstall_pro_component)
+ monkeypatch.setattr(updater, "_write_version_marker", lambda _version: None)
monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _install_root: None)
progresses = [
@@ -545,6 +547,68 @@ async def _after_uninstall():
assert len(archived_marker) == 1
+@pytest.mark.asyncio
+async def test_perform_pro_bundle_downgrade_uses_restart_handoff_spawn(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ from flocks.updater import deploy as deploy_mod
+
+ flocks_root = tmp_path / "flocks-root"
+ run_dir = flocks_root / "run"
+ run_dir.mkdir(parents=True)
+ (run_dir / "pro-bundle-installed.json").write_text(
+ json.dumps(
+ {
+ "bundle_version": "v2026.6.24",
+ "core_version": "v2026.6.21",
+ "flockspro_component_version": "v2026.6.24-pro",
+ "installed_at": "2026-06-24T08:00:00+00:00",
+ }
+ ),
+ encoding="utf-8",
+ )
+ install_root = tmp_path / "install-root"
+ install_root.mkdir()
+ handoff_argv = [sys.executable, "-m", "flocks.updater.restart_handoff"]
+ spawn_calls: list[tuple[list[str], Path]] = []
+
+ async def fake_uninstall_pro_component(*, uv_path, install_root, env):
+ return None
+
+ async def fake_sleep(_seconds: float) -> None:
+ return None
+
+ monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root))
+ monkeypatch.setattr(deploy_mod, "detect_deploy_mode", lambda: "source")
+ monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root)
+ monkeypatch.setattr(updater, "get_current_version", lambda: "2026.6.24")
+ monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True)
+ monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None)
+ monkeypatch.setattr(updater, "_uninstall_pro_component", fake_uninstall_pro_component)
+ monkeypatch.setattr(updater, "_write_version_marker", lambda _version: None)
+ monkeypatch.setattr(updater, "_build_restart_argv", lambda _install_root: [sys.executable])
+ monkeypatch.setattr(updater, "_build_restart_handoff_argv", lambda *_args, **_kwargs: handoff_argv)
+ monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep)
+ monkeypatch.setattr(
+ updater,
+ "_spawn_restart_handoff",
+ lambda argv, *, cwd: spawn_calls.append((argv, cwd)) or SimpleNamespace(pid=4321),
+ )
+ monkeypatch.setattr(
+ updater.subprocess,
+ "Popen",
+ lambda *_args, **_kwargs: pytest.fail("downgrade must use the restart handoff spawn helper"),
+ )
+ monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code)))
+
+ with pytest.raises(SystemExit, match="0"):
+ async for _step in updater.perform_pro_bundle_downgrade():
+ pass
+
+ assert spawn_calls == [(handoff_argv, install_root)]
+
+
@pytest.mark.asyncio
async def test_load_console_session_token_falls_back_to_shared_session(
monkeypatch: pytest.MonkeyPatch,
@@ -811,7 +875,7 @@ def stream(self, method, url, headers=None):
@pytest.mark.asyncio
-async def test_perform_pro_bundle_install_replaces_core_and_installs_wheel(
+async def test_core_pro_bundle_requires_source_upgrade_handoff(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
@@ -884,20 +948,12 @@ async def _fake_run_async(cmd, **_kwargs):
monkeypatch.setattr(updater, "_run_async", _fake_run_async)
progresses = [step async for step in updater.perform_pro_bundle_install(restart=False)]
- assert progresses[-1].stage == "done"
- assert (install_root / "new_core.py").is_file()
- assert not (install_root / "old_core.py").exists()
- assert any(cmd[:2] == ["/usr/bin/uv", "sync"] for cmd in captured)
- pip_installs = [cmd for cmd in captured if cmd[:3] == ["/usr/bin/uv", "pip", "install"]]
- assert pip_installs
- assert "--no-deps" in pip_installs[-1]
- assert str(wheel.name) in pip_installs[-1][-1]
- marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json"
- assert marker.is_file()
- marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8"))
- assert version_writes == ["2026.5.10"]
- assert marker_payload["bundle_version"] == "v2026.5.11"
- assert marker_payload["core_version"] == "v2026.5.10"
+ assert progresses[-1].stage == "error"
+ assert "detached handoff" in progresses[-1].message
+ assert not (install_root / "new_core.py").exists()
+ assert (install_root / "old_core.py").exists()
+ assert captured == []
+ assert version_writes == []
@pytest.mark.asyncio
@@ -977,6 +1033,8 @@ async def _fake_run_async(cmd, **_kwargs):
progresses = [step async for step in updater.perform_pro_bundle_install(restart=False)]
assert progresses[-1].stage == "done"
+ assert "backing_up" not in [step.stage for step in progresses]
+ assert "syncing" not in [step.stage for step in progresses]
assert any("Keeping local Flocks v2026.6.18" in step.message for step in progresses)
assert (install_root / "current_core.py").is_file()
assert not (install_root / "older_core.py").exists()
diff --git a/webui/src/locales/en-US/update.json b/webui/src/locales/en-US/update.json
index e775bc060..a8a6d9111 100644
--- a/webui/src/locales/en-US/update.json
+++ b/webui/src/locales/en-US/update.json
@@ -37,7 +37,6 @@
"fetchingPro": "Downloading Pro bundle",
"backing_up": "Backing up current version",
"applying": "Applying new version",
- "syncing": "Syncing backend dependencies",
"restarting": "Restarting service",
"done": "Done",
"error": "Error"
diff --git a/webui/src/locales/zh-CN/update.json b/webui/src/locales/zh-CN/update.json
index 5d8e90af9..68b0ecaec 100644
--- a/webui/src/locales/zh-CN/update.json
+++ b/webui/src/locales/zh-CN/update.json
@@ -37,7 +37,6 @@
"fetchingPro": "下载 Pro bundle",
"backing_up": "备份当前版本",
"applying": "应用新版本",
- "syncing": "同步后端依赖",
"restarting": "重启服务",
"done": "完成",
"error": "出错"