From 6cf3cdcb9948f0b50906b6291c72c8cc694c3d5b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:01:34 +0800 Subject: [PATCH] fix(update): activate managed services when only extensions are blocked `loopx update apply` restarted the LaunchAgent-managed status/chat services only in its fully-clean branch. A blocked enabled extension provider made the run report `review_or_rollback` even though the release snapshot was installed and core doctor readback passed, so status/chat kept serving the previous release until an operator restarted them by hand. Decide the restart from the runtime install plus its core doctor readback, report blocked providers as a repair action instead of a rollback, and move the LaunchAgent restart into a bounded runtime_activation owner so the update boundary keeps describing what was installed rather than how it starts serving. The new archive-path cases carry the same native-Windows skip as the existing archive update test, because `execute_update_plan` fails closed before the installer on that platform. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/book/chapters/appendix-reference.md | 4 + docs/book/en/chapters/appendix-reference.md | 5 ++ loopx/runtime_activation.py | 74 +++++++++++++++ loopx/self_update.py | 95 ++++++++++---------- loopx/semantics/inventory_v0.json | 2 +- tests/test_self_update_runtime_activation.py | 84 +++++++++++++++-- 6 files changed, 212 insertions(+), 52 deletions(-) create mode 100644 loopx/runtime_activation.py diff --git a/docs/book/chapters/appendix-reference.md b/docs/book/chapters/appendix-reference.md index 5a08b736e9..39c289f0c1 100644 --- a/docs/book/chapters/appendix-reference.md +++ b/docs/book/chapters/appendix-reference.md @@ -142,6 +142,10 @@ archive 的默认来源是公开 `stable` ref。`--ref main` 是 maintainer/dev archive 继续管理 release snapshot,live checkout 仍需显式更新。成功退出不代表每个 Host automation、Goal migration 或 Extension Provider 都已更新。 +当 runtime 安装与核心 `doctor` 读回都通过后,`update apply` 会同时重启 LaunchAgent 托管的 +`status` 与 `chat` 服务,让新安装的行为真正开始服务;此时即使有 enabled Extension Provider +被阻塞,也只会被报告为待修复,而不会阻止这次激活——否则旧服务会继续按上一个 release 运行。 + 升级后按使用面继续验证: - `loopx doctor`:wrapper、release manifest、Python import、skill delivery 与 Host integration; diff --git a/docs/book/en/chapters/appendix-reference.md b/docs/book/en/chapters/appendix-reference.md index 10cf9513dd..85c98d6948 100644 --- a/docs/book/en/chapters/appendix-reference.md +++ b/docs/book/en/chapters/appendix-reference.md @@ -146,6 +146,11 @@ owner: pip and pipx keep the PyPI environment, archive installs keep the release checkouts remain explicit. A successful exit does not prove that every Host automation, Goal migration, or Extension Provider is updated. +Once the runtime install and its core `doctor` readback pass, `update apply` also restarts the +LaunchAgent-managed `status` and `chat` services so the installed behavior actually serves. A blocked +enabled Extension Provider is reported for repair instead of preventing that activation, because a +stale service would otherwise keep serving the previous release. + Validate the surfaces you use: - `loopx doctor`: wrapper, release manifest, Python import, skill delivery, and Host integration; diff --git a/loopx/runtime_activation.py b/loopx/runtime_activation.py new file mode 100644 index 0000000000..fd6f1af0de --- /dev/null +++ b/loopx/runtime_activation.py @@ -0,0 +1,74 @@ +"""Make an installed LoopX runtime serve LaunchAgent-managed services. + +Replacing the release snapshot does not move the status/chat services that are +already running; this module owns that transition so the update boundary stays +about what was installed, not about how it starts serving. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + + +def restart_managed_loopx_services() -> list[str]: + """Best-effort restart of user LaunchAgent-managed LoopX services on macOS. + + After ``loopx update`` replaces the installed release, running status/chat + services still belong to the previous release. Restarting the managed + LaunchAgents makes them run the new ``loopx`` immediately, so the dashboard + and desktop shell keep working without a release-identity mismatch. + """ + if sys.platform != "darwin": + return [] + agents_dir = Path.home() / "Library" / "LaunchAgents" + if not agents_dir.is_dir(): + return [] + labels: list[str] = [] + for plist in sorted(agents_dir.glob("*.plist")): + stem = plist.stem.lower() + if "loopx" not in stem and "goal-harness" not in stem: + continue + if not (stem.endswith(".status") or stem.endswith(".chat")): + continue + labels.append(plist.stem) + restarted: list[str] = [] + uid = os.getuid() if hasattr(os, "getuid") else 0 + for label in labels: + result = subprocess.run( + ["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"], + capture_output=True, + text=True, encoding="utf-8", errors="replace", + timeout=30, + ) + if result.returncode == 0: + restarted.append(label) + return restarted + + +def restart_services_for_runtime_activation( + *, + changes_applied: bool, + runtime_ready: bool, +) -> dict[str, Any]: + """Report and perform the restart that moves services onto the new release. + + Restarting is what makes the installed behavior actually reach the operator, + so it follows the runtime install plus its core doctor readback instead of + the health of unrelated enabled extension providers. Tying the restart to + full extension health left status/chat silently serving the previous release + while the update reported a rollback-only outcome. + """ + + if not (changes_applied and runtime_ready): + return { + "restarted_services": [], + "restart_status": "skipped_runtime_not_activated", + } + return { + "restarted_services": restart_managed_loopx_services(), + "restart_status": "restarted", + } diff --git a/loopx/self_update.py b/loopx/self_update.py index 162b74322c..65d88978d4 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -13,6 +13,7 @@ from .doctor import collect_doctor from .install_contract import NO_CLONE_INSTALL_URL +from .runtime_activation import restart_services_for_runtime_activation from .self_update_download import run_archive_installer @@ -857,41 +858,6 @@ def build_update_plan( } -def restart_managed_loopx_services() -> list[str]: - """Best-effort restart of user LaunchAgent-managed LoopX services on macOS. - - After ``loopx update`` replaces the installed release, running status/chat - services still belong to the previous release. Restarting the managed - LaunchAgents makes them run the new ``loopx`` immediately, so the dashboard - and desktop shell keep working without a release-identity mismatch. - """ - if sys.platform != "darwin": - return [] - agents_dir = Path.home() / "Library" / "LaunchAgents" - if not agents_dir.is_dir(): - return [] - labels: list[str] = [] - for plist in sorted(agents_dir.glob("*.plist")): - stem = plist.stem.lower() - if "loopx" not in stem and "goal-harness" not in stem: - continue - if not (stem.endswith(".status") or stem.endswith(".chat")): - continue - labels.append(plist.stem) - restarted: list[str] = [] - uid = os.getuid() if hasattr(os, "getuid") else 0 - for label in labels: - result = subprocess.run( - ["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"], - capture_output=True, - text=True, encoding="utf-8", errors="replace", - timeout=30, - ) - if result.returncode == 0: - restarted.append(label) - return restarted - - def _execute_python_distribution_update( payload: dict[str, Any], *, @@ -991,22 +957,29 @@ def _execute_python_distribution_update( execution[f"{step}_stdout_tail"] = result.stdout[-2000:] execution[f"{step}_stderr_tail"] = result.stderr[-2000:] - required_steps = ( + runtime_steps = ( "install", "workflow_skills", "slash_commands", "doctor", - "extension_doctor", ) - ok = all( - step in results and results[step].returncode == 0 for step in required_steps + runtime_ready = all( + step in results and results[step].returncode == 0 for step in runtime_steps + ) + extension_ready = ( + "extension_doctor" in results and results["extension_doctor"].returncode == 0 ) updated = dict(payload) updated["execution"] = execution - updated["ok"] = ok + updated["ok"] = runtime_ready and extension_ready updated["changes_applied"] = results["install"].returncode == 0 - if ok: - execution["restarted_services"] = restart_managed_loopx_services() + execution.update( + restart_services_for_runtime_activation( + changes_applied=updated["changes_applied"], + runtime_ready=runtime_ready, + ) + ) + if updated["ok"]: updated["recommended_action"] = ( "PyPI update and host-material readback passed; use the new LoopX process" ) @@ -1017,6 +990,19 @@ def _execute_python_distribution_update( "requires_explicit_approval": False, "reason": updated["recommended_action"], } + elif runtime_ready: + updated["recommended_action"] = ( + "the updated runtime is installed and serving; repair the blocked " + "enabled extension providers, then rerun " + "`loopx extension doctor --all-enabled --execute`" + ) + updated["next_action"] = { + "kind": "repair_blocked_extensions", + "command": "loopx extension doctor --all-enabled --execute", + "mutating": False, + "requires_explicit_approval": False, + "reason": updated["recommended_action"], + } else: backup = ( payload.get("plan", {}).get("backup") @@ -1140,15 +1126,35 @@ def execute_update_plan( ) else: execution["extension_doctor_status"] = "skipped_release_update_failed" + runtime_ready = install_result.returncode == 0 and doctor_result.returncode == 0 updated = dict(payload) updated["execution"] = execution updated["ok"] = ( - install_result.returncode == 0 - and doctor_result.returncode == 0 + runtime_ready and extension_doctor_result is not None and extension_doctor_result.returncode == 0 ) updated["changes_applied"] = install_result.returncode == 0 + execution.update( + restart_services_for_runtime_activation( + changes_applied=updated["changes_applied"], + runtime_ready=runtime_ready, + ) + ) + if not updated["ok"] and runtime_ready: + updated["recommended_action"] = ( + "the updated runtime is installed and serving; repair the blocked " + "enabled extension providers, then rerun " + "`loopx extension doctor --all-enabled --execute`" + ) + updated["next_action"] = { + "kind": "repair_blocked_extensions", + "command": "loopx extension doctor --all-enabled --execute", + "mutating": False, + "requires_explicit_approval": False, + "reason": updated["recommended_action"], + } + return updated if not updated["ok"]: updated["recommended_action"] = ( "inspect update execution tails and restore from rollback plan if needed" @@ -1162,7 +1168,6 @@ def execute_update_plan( "reason": updated["recommended_action"], } return updated - execution["restarted_services"] = restart_managed_loopx_services() updated["recommended_action"] = ( "archive update and readback passed; use the new LoopX process" ) diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index d5620542d4..d12253e8c7 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -902,7 +902,7 @@ ] }, "summary": { - "source_files": 1175, + "source_files": 1176, "python_enums": 103, "python_closed_sets": 495, "python_literal_aliases": 8, diff --git a/tests/test_self_update_runtime_activation.py b/tests/test_self_update_runtime_activation.py index 1039fca452..7d41154cf3 100644 --- a/tests/test_self_update_runtime_activation.py +++ b/tests/test_self_update_runtime_activation.py @@ -18,8 +18,8 @@ build_update_plan, execute_update_plan, resolve_update_action, - restart_managed_loopx_services, ) +from loopx.runtime_activation import restart_managed_loopx_services REPO_ROOT = Path(__file__).resolve().parents[1] @@ -325,7 +325,7 @@ def test_python_distribution_apply_uses_the_owning_interpreter_pip() -> None: side_effect=[passed, passed, passed, passed, passed], ) as run, mock.patch( - "loopx.self_update.restart_managed_loopx_services", + "loopx.runtime_activation.restart_managed_loopx_services", return_value=["com.loopx.status"], ), ): @@ -373,7 +373,7 @@ def test_pipx_distribution_apply_preserves_the_pipx_environment() -> None: side_effect=[passed, passed, passed, passed, passed], ) as run, mock.patch( - "loopx.self_update.restart_managed_loopx_services", + "loopx.runtime_activation.restart_managed_loopx_services", return_value=[], ), ): @@ -383,6 +383,78 @@ def test_pipx_distribution_apply_preserves_the_pipx_environment() -> None: assert run.call_args_list[0].args[0] == ["pipx", "upgrade", "loopx-preview"] +@pytest.mark.skipif( + os.name == "nt", + reason="archive snapshot updates require the POSIX installer path", +) +def test_archive_apply_restarts_managed_services_when_only_extensions_are_blocked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = build_update_plan(action="apply", doctor_payload=doctor_payload()) + + assert payload["install_lifecycle"]["execution_driver"] == "archive_snapshot" + + installed = subprocess.CompletedProcess([], 0, "", "") + blocked = subprocess.CompletedProcess([], 1, '{"blocked_count": 1}', "") + monkeypatch.setenv("LOOPX_PYTHON", sys.executable) + monkeypatch.setattr( + "loopx.self_update.run_archive_installer", + lambda *_args, **_kwargs: (installed, {"stage": "installer_execution"}), + ) + monkeypatch.setattr( + "loopx.self_update.subprocess.run", + mock.Mock(side_effect=[installed, blocked]), + ) + monkeypatch.setattr( + "loopx.runtime_activation.restart_managed_loopx_services", + lambda: ["com.loopx.chat"], + ) + + result = execute_update_plan(payload) + + # The runtime is installed and serving, so the managed services must move + # onto it even though an unrelated enabled extension provider is blocked. + assert result["changes_applied"] is True + assert result["execution"]["restarted_services"] == ["com.loopx.chat"] + assert result["execution"]["restart_status"] == "restarted" + assert result["ok"] is False + assert result["next_action"]["kind"] == "repair_blocked_extensions" + assert result["next_action"]["mutating"] is False + assert "rollback" not in result["recommended_action"] + + +@pytest.mark.skipif( + os.name == "nt", + reason="archive snapshot updates require the POSIX installer path", +) +def test_archive_apply_skips_managed_service_restart_when_the_runtime_is_unhealthy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = build_update_plan(action="apply", doctor_payload=doctor_payload()) + + failed_install = subprocess.CompletedProcess([], 1, "", "installer failed") + monkeypatch.setenv("LOOPX_PYTHON", sys.executable) + monkeypatch.setattr( + "loopx.self_update.run_archive_installer", + lambda *_args, **_kwargs: (failed_install, {"stage": "installer_execution"}), + ) + monkeypatch.setattr( + "loopx.self_update.subprocess.run", + mock.Mock(return_value=failed_install), + ) + restarted = mock.Mock(return_value=["com.loopx.chat"]) + monkeypatch.setattr("loopx.runtime_activation.restart_managed_loopx_services", restarted) + + result = execute_update_plan(payload) + + assert result["changes_applied"] is False + assert result["ok"] is False + assert result["execution"]["restarted_services"] == [] + assert result["execution"]["restart_status"] == "skipped_runtime_not_activated" + assert result["next_action"]["kind"] == "review_or_rollback" + restarted.assert_not_called() + + def test_live_checkout_apply_never_mutates_git_or_switches_install_channels() -> None: doctor = doctor_payload() doctor["package"] = {"install_kind": "live_checkout", "release_root": None} @@ -478,7 +550,7 @@ def test_restart_managed_loopx_services_restarts_only_loopx_launchagents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("loopx.self_update.sys.platform", "darwin") + monkeypatch.setattr("loopx.runtime_activation.sys.platform", "darwin") monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) agents = tmp_path / "Library" / "LaunchAgents" agents.mkdir(parents=True) @@ -496,7 +568,7 @@ def fake_run(args: list[str], **kwargs: object) -> subprocess.CompletedProcess[s calls.append(list(args)) return subprocess.CompletedProcess(args, 0, "", "") - monkeypatch.setattr("loopx.self_update.subprocess.run", fake_run) + monkeypatch.setattr("loopx.runtime_activation.subprocess.run", fake_run) restarted = restart_managed_loopx_services() assert set(restarted) == { @@ -541,7 +613,7 @@ def fake_run( "loopx.self_update._installer_env_for_source", lambda *_args, **_kwargs: {}, ) - monkeypatch.setattr("loopx.self_update.subprocess.run", fake_run) + monkeypatch.setattr("loopx.runtime_activation.subprocess.run", fake_run) updated = execute_update_plan(payload)