Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/book/chapters/appendix-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions docs/book/en/chapters/appendix-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 74 additions & 0 deletions loopx/runtime_activation.py
Original file line number Diff line number Diff line change
@@ -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")):

Check warning on line 35 in loopx/runtime_activation.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace chained "endswith" calls with a single call using a tuple argument.

See more on https://sonarcloud.io/project/issues?id=huangruiteng_loopx&issues=AaCluhay3nKXUfCWJb4Q&open=AaCluhay3nKXUfCWJb4Q&pullRequest=4458
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",
}
95 changes: 50 additions & 45 deletions loopx/self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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],
*,
Expand Down Expand Up @@ -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"
)
Expand All @@ -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")
Expand Down Expand Up @@ -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"
Expand All @@ -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"
)
Expand Down
2 changes: 1 addition & 1 deletion loopx/semantics/inventory_v0.json
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@
]
},
"summary": {
"source_files": 1175,
"source_files": 1176,
"python_enums": 103,
"python_closed_sets": 495,
"python_literal_aliases": 8,
Expand Down
84 changes: 78 additions & 6 deletions tests/test_self_update_runtime_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"],
),
):
Expand Down Expand Up @@ -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=[],
),
):
Expand All @@ -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}
Expand Down Expand Up @@ -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)
Expand All @@ -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) == {
Expand Down Expand Up @@ -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)

Expand Down