From 0fb749c93881990c4c8beefe7b1739005e059d51 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:24:09 +0800 Subject: [PATCH 1/2] fix(update): preserve the selected runtime throughout rollback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../self-update-explicit-runtime-route-v0.md | 35 ++++++++++ ...-update-explicit-runtime-route-v0.zh-CN.md | 26 +++++++ loopx/cli_commands/support_control_update.py | 32 ++++++--- loopx/self_update.py | 39 ++++++++--- tests/test_self_update_runtime_activation.py | 68 +++++++++++++++++++ 5 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md create mode 100644 docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md diff --git a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md new file mode 100644 index 0000000000..0900c6df33 --- /dev/null +++ b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md @@ -0,0 +1,35 @@ +# Preserve the selected runtime during rollback + +Status: implementation proposal. + +[中文](self-update-explicit-runtime-route-v0.zh-CN.md) + +## Problem and ownership + +An explicitly routed `update --rollback` dropped its registry and runtime root +when planning and launching the target runtime. With two populated default +roots, the target's read-only authority check rejected the implicit route. The +parent reported a format incompatibility even though the selected store was +readable. A successful check could also be followed by diagnostics against a +different store. + +The existing updater owns this defect. Rollback and forward update resolve the +same selected route through the existing registry/runtime resolver, then pass +that route to planning and every child check. The existing argument builder is +shared; callers that supply no route directly retain their implicit behavior. +This adds no state, provider, migration, permission, scheduler or executor. + +## Failure and acceptance boundaries + +Authority compatibility remains a read-only `--require-current` check before +the launcher changes. Its exit status and bounded output remain in the existing +execution readback, and failure is reported without guessing its cause. A +failed core doctor still restores the previous launcher; an extension failure +still reports the activated runtime with its provider unavailable. + +Acceptance follows the real rollback CLI through planning and all three child +checks, including each failure boundary and paths containing spaces. Existing +archive, pip and pipx update route regressions remain applicable. Installed +acceptance must distinguish an isolated rollback from changing the user's +active launcher or restoring a production store. This repair does not explain +why a separate installer selected an older source revision. diff --git a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md new file mode 100644 index 0000000000..b6a7586769 --- /dev/null +++ b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md @@ -0,0 +1,26 @@ +# 回滚全程保留所选运行目录 + +状态:实现提案。 + +[English](self-update-explicit-runtime-route-v0.md) + +## 问题与责任层 + +显式指定 registry 与 runtime root 的 `update --rollback`,在规划和启动目标运行时 +时丢失了这两个参数。两个默认目录都已有状态时,目标运行时的只读权威兼容检查会 +拒绝隐式路由,父进程却将它报告为格式不兼容;即使该检查通过,后续诊断也可能 +读取另一个目录。 + +修复位于既有更新器。回滚与前向更新复用原有 registry/runtime 解析器选择路由, +将同一路由传给规划诊断及每个子进程,并共用参数构建函数。直接调用且未提供路由 +的调用者仍保留原有隐式行为,不增加状态、provider、迁移、权限、调度器或执行者。 + +## 失败与验收边界 + +兼容检查仍在修改启动器之前,以只读 `--require-current` 执行。退出码和有界输出 +保留在既有 execution 读回中,失败时不猜测原因。核心诊断失败仍恢复原启动器; +扩展失败仍如实报告已激活运行时与不可用 provider,不把失败改成成功。 + +验收从实际回滚 CLI 贯穿规划与三个子检查,覆盖各阶段拒绝和含空格路径,并保留 +archive、pip、pipx 前向更新的既有路由回归。安装验收分别说明隔离回滚、本人实际 +启动器变化与生产存储恢复的范围。本修复不解释另一安装过程为何选择了较旧源码。 diff --git a/loopx/cli_commands/support_control_update.py b/loopx/cli_commands/support_control_update.py index 6e36819a8b..74864632ed 100644 --- a/loopx/cli_commands/support_control_update.py +++ b/loopx/cli_commands/support_control_update.py @@ -49,6 +49,19 @@ UPDATE_CONTROL_COMMANDS = {"update", "upgrade"} +def _selected_runtime_route( + registry_path: Path, runtime_root: str | None, *, registry_was_supplied: bool, +) -> tuple[Path, Path]: + selected_registry = registry_path if registry_was_supplied else explicit_global_registry( + runtime_root, registry_path=registry_path, + ) + selected_runtime = resolve_runtime_root( + load_registry(selected_registry) if selected_registry.exists() else {}, + runtime_root, registry_path=selected_registry, + ) + return selected_registry, selected_runtime + + def register_update_command( subparsers: argparse._SubParsersAction, add_subcommand_format: AddFormat, @@ -219,9 +232,16 @@ def renderer(value): print_payload(payload, output_format(args), renderer) return 0 if payload.get("ok") else 1 if args.rollback: - payload = build_rollback_plan(release_id=args.rollback) + selected_registry, selected_runtime = _selected_runtime_route( + registry_path, args.runtime_root, registry_was_supplied=registry_was_supplied, + ) + payload = build_rollback_plan( + release_id=args.rollback, registry_path=selected_registry, + runtime_root=str(selected_runtime), + ) payload = execute_rollback_plan( - payload, timeout_seconds=args.timeout_seconds + payload, timeout_seconds=args.timeout_seconds, + registry_path=selected_registry, runtime_root=str(selected_runtime), ) else: doctor_payload = None @@ -276,12 +296,8 @@ def renderer(value): from ..control_plane.heartbeat.installed_prompt_update import ( update_with_prompts, ) - selected_registry = registry_path if registry_was_supplied else explicit_global_registry( - args.runtime_root, registry_path=registry_path, - ) - selected_runtime = resolve_runtime_root( - load_registry(selected_registry) if selected_registry.exists() else {}, - args.runtime_root, registry_path=selected_registry, + selected_registry, selected_runtime = _selected_runtime_route( + registry_path, args.runtime_root, registry_was_supplied=registry_was_supplied, ) payload = update_with_prompts( payload, registry=selected_registry, diff --git a/loopx/self_update.py b/loopx/self_update.py index c340cf5272..5273f04a58 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -576,8 +576,13 @@ def build_rollback_plan( release_id: str, doctor_payload: dict[str, Any] | None = None, home: Path | None = None, + registry_path: Path | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: - doctor = doctor_payload or collect_doctor() + doctor = doctor_payload or ( + collect_doctor(registry_path=registry_path, runtime_root_override=runtime_root) + if registry_path is not None or runtime_root is not None else collect_doctor() + ) current_release_root = _release_root_from_doctor(doctor) releases_dir = _user_releases_dir(home) releases = _list_release_roots(releases_dir) @@ -1174,6 +1179,15 @@ def _execute_python_distribution_update( return updated +def _runtime_route_arguments(registry_path: Path | None, runtime_root: str | None) -> list[str]: + arguments = [] + if registry_path is not None: + arguments += ["--registry", str(registry_path.resolve())] + if runtime_root is not None: + arguments += ["--runtime-root", runtime_root] + return arguments + + def execute_update_plan( payload: dict[str, Any], *, timeout_seconds: int = 600, registry_path: Path | None = None, runtime_root: str | None = None, @@ -1181,11 +1195,7 @@ def execute_update_plan( ) -> dict[str, Any]: if not payload.get("plan", {}).get("apply_supported", True): return dict(payload, ok=False, changes_applied=False) - route_arguments = [] - if registry_path is not None: - route_arguments += ["--registry", str(registry_path.resolve())] - if runtime_root is not None: - route_arguments += ["--runtime-root", runtime_root] + route_arguments = _runtime_route_arguments(registry_path, runtime_root) lifecycle = ( payload.get("install_lifecycle") if isinstance(payload.get("install_lifecycle"), dict) @@ -1375,6 +1385,8 @@ def execute_rollback_plan( *, timeout_seconds: int = 600, home: Path | None = None, + registry_path: Path | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: if os.name == "nt": updated = dict(payload) @@ -1394,6 +1406,7 @@ def execute_rollback_plan( return payload release_root = Path(str(selected_release_root)) target_script = _release_script(release_root) + route_arguments = _runtime_route_arguments(registry_path, runtime_root) loopx_bin = _user_loopx_bin(home) previous_link_target = os.readlink(loopx_bin) if loopx_bin.is_symlink() else None execution: dict[str, Any] = { @@ -1405,14 +1418,19 @@ def execute_rollback_plan( updated = dict(payload) try: compatible = subprocess.run( - [str(target_script), "--format", "json", "authority-archive", "upgrade", + [str(target_script), *route_arguments, "--format", "json", "authority-archive", "upgrade", "--all-known", "--require-current"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, ) + execution.update( + compatibility_returncode=compatible.returncode, + compatibility_stdout_tail=compatible.stdout[-2000:], + compatibility_stderr_tail=compatible.stderr[-2000:], + ) if compatible.returncode != 0: - raise RuntimeError("Rollback target cannot read current authority formats. " - "Keep the current runtime; recover a verified backup into an isolated store first.") + raise RuntimeError("Rollback target authority check failed. Keep the current runtime " + "and inspect the compatibility readback before retrying.") loopx_bin.parent.mkdir(parents=True, exist_ok=True) temp_link = loopx_bin.with_name(f".{loopx_bin.name}.rollback.{os.getpid()}") if temp_link.exists() or temp_link.is_symlink(): @@ -1420,7 +1438,7 @@ def execute_rollback_plan( temp_link.symlink_to(target_script) os.replace(temp_link, loopx_bin) doctor_result = subprocess.run( - [str(loopx_bin), "--format", "json", "doctor"], + [str(loopx_bin), *route_arguments, "--format", "json", "doctor"], text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout_seconds, @@ -1436,6 +1454,7 @@ def execute_rollback_plan( extension_doctor_result = subprocess.run( [ str(loopx_bin), + *route_arguments, "--format", "json", "extension", diff --git a/tests/test_self_update_runtime_activation.py b/tests/test_self_update_runtime_activation.py index f7dcf6d1bd..7dbc6a6b34 100644 --- a/tests/test_self_update_runtime_activation.py +++ b/tests/test_self_update_runtime_activation.py @@ -979,6 +979,74 @@ def run(command, **kwargs): assert installer_env["LOOPX_RUNTIME_ROOT"] == str(runtime) +@pytest.mark.skipif(os.name == "nt", reason="POSIX snapshot rollback") +@pytest.mark.parametrize("failed_stage", [None, "compatibility", "doctor", "extensions"]) +def test_rollback_cli_preserves_selected_route_and_failure_boundaries( + tmp_path, monkeypatch, capsys, failed_stage, +): + from loopx.cli import main + + runtime = tmp_path / "selected runtime" + runtime.mkdir() + registry = runtime / "registry.global.json" + registry.write_text(json.dumps({"goals": [], "common_runtime_root": str(runtime)})) + releases = tmp_path / "releases" + current, target = releases / "current", releases / "target" + for release in (current, target): + (release / "scripts").mkdir(parents=True) + (release / "scripts/loopx").write_text("#!/bin/sh\nexit 0\n") + launcher = tmp_path / "bin/loopx" + launcher.parent.mkdir() + launcher.symlink_to(current / "scripts/loopx") + monkeypatch.setattr("loopx.self_update._user_loopx_bin", lambda *_: launcher) + monkeypatch.setattr("loopx.self_update._user_releases_dir", lambda *_: releases) + snapshot = doctor_payload() + snapshot["package"]["release_root"] = str(current) + planning_calls, calls = [], [] + + def collect(**kwargs): + planning_calls.append(kwargs) + return snapshot + + def run(command, **kwargs): + stage = ("compatibility" if "authority-archive" in command else + "extensions" if "extension" in command else "doctor") + calls.append((stage, command)) + passed = stage != failed_stage + return subprocess.CompletedProcess( + command, 0 if passed else 1, + json.dumps({"ok": passed, "stage": stage, "error": None if passed else "synthetic refusal"}), + "" if passed else "synthetic diagnostic", + ) + + monkeypatch.setattr("loopx.self_update.collect_doctor", collect) + monkeypatch.setattr("loopx.self_update.subprocess.run", run) + exit_code = main([ + "--registry", str(registry), "--runtime-root", str(runtime), + "--format", "json", "update", "--rollback", "target", + ]) + result = json.loads(capsys.readouterr().out) + assert exit_code == (0 if failed_stage is None else 1) + assert result["ok"] is (failed_stage is None) + assert planning_calls == [{"registry_path": registry, "runtime_root_override": str(runtime)}] + assert [stage for stage, _ in calls] == { + None: ["compatibility", "doctor", "extensions"], + "compatibility": ["compatibility"], + "doctor": ["compatibility", "doctor"], + "extensions": ["compatibility", "doctor", "extensions"], + }[failed_stage] + for _, command in calls: + assert command[command.index("--registry") + 1] == str(registry.resolve()) + assert command[command.index("--runtime-root") + 1] == str(runtime) + compatibility = calls[0][1] + assert "--require-current" in compatibility and "--execute" not in compatibility + assert result["execution"]["compatibility_returncode"] == (1 if failed_stage == "compatibility" else 0) + if failed_stage == "compatibility": + assert "synthetic refusal" in result["execution"]["compatibility_stdout_tail"] + expected_release = current if failed_stage in {"compatibility", "doctor"} else target + assert launcher.resolve() == expected_release / "scripts/loopx" + + @pytest.mark.skipif(os.name == "nt", reason="POSIX archive installation ownership") @pytest.mark.parametrize("bin_target", [ None, "", "default_alias", "custom", "custom_launcher_link", "relative_custom", From 55eda1c6e969a2099497b7b946fb5064128e6edc Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:40:48 +0800 Subject: [PATCH 2/2] fix(updater): expose failed rollback checks in CLI output Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../self-update-explicit-runtime-route-v0.md | 1 + ...-update-explicit-runtime-route-v0.zh-CN.md | 3 ++- loopx/self_update.py | 25 +++++++++++++------ tests/test_self_update_runtime_activation.py | 8 ++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md index 0900c6df33..503de4571f 100644 --- a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md +++ b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md @@ -24,6 +24,7 @@ This adds no state, provider, migration, permission, scheduler or executor. Authority compatibility remains a read-only `--require-current` check before the launcher changes. Its exit status and bounded output remain in the existing execution readback, and failure is reported without guessing its cause. A +failed check also exposes its bounded stdout/stderr in the default CLI output. A failed core doctor still restores the previous launcher; an extension failure still reports the activated runtime with its provider unavailable. diff --git a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md index b6a7586769..14dc643cc7 100644 --- a/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md +++ b/docs/architecture/rfcs/self-update-explicit-runtime-route-v0.zh-CN.md @@ -18,7 +18,8 @@ ## 失败与验收边界 兼容检查仍在修改启动器之前,以只读 `--require-current` 执行。退出码和有界输出 -保留在既有 execution 读回中,失败时不猜测原因。核心诊断失败仍恢复原启动器; +保留在既有 execution 读回中,默认 CLI 同时展示失败检查的有界输出,不猜测原因。 +核心诊断失败仍恢复原启动器; 扩展失败仍如实报告已激活运行时与不可用 provider,不把失败改成成功。 验收从实际回滚 CLI 贯穿规划与三个子检查,覆盖各阶段拒绝和含空格路径,并保留 diff --git a/loopx/self_update.py b/loopx/self_update.py index 5273f04a58..4ec51ad6e9 100644 --- a/loopx/self_update.py +++ b/loopx/self_update.py @@ -1556,14 +1556,23 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str: ["", "## Rollback Command", "", "```bash", str(rollback_command), "```"] ) if execution: - lines.extend( - [ - "", - "## Execution", - "", - f"- Doctor return code: `{execution.get('doctor_returncode')}`", - ] - ) + lines.extend(["", "## Execution", ""]) + for stage, label in ( + ("compatibility", "Authority compatibility"), + ("doctor", "Doctor"), + ("extension_doctor", "Extension doctor"), + ): + returncode = execution.get(f"{stage}_returncode") + if returncode is None: + continue + lines.append(f"- {label} return code: `{returncode}`") + if returncode != 0: + for stream in ("stdout", "stderr"): + output = execution.get(f"{stage}_{stream}_tail") + if output: + lines.extend(["", f"### {label} {stream}", "", "```text", str(output), "```"]) + if execution.get("error"): + lines.extend(["", f"Error: {execution['error']}"]) return "\n".join(lines) + "\n" source = payload.get("source") if isinstance(payload.get("source"), dict) else {} diff --git a/tests/test_self_update_runtime_activation.py b/tests/test_self_update_runtime_activation.py index 7dbc6a6b34..5406587558 100644 --- a/tests/test_self_update_runtime_activation.py +++ b/tests/test_self_update_runtime_activation.py @@ -985,6 +985,7 @@ def test_rollback_cli_preserves_selected_route_and_failure_boundaries( tmp_path, monkeypatch, capsys, failed_stage, ): from loopx.cli import main + from loopx.self_update import render_update_plan_markdown runtime = tmp_path / "selected runtime" runtime.mkdir() @@ -1043,6 +1044,13 @@ def run(command, **kwargs): assert result["execution"]["compatibility_returncode"] == (1 if failed_stage == "compatibility" else 0) if failed_stage == "compatibility": assert "synthetic refusal" in result["execution"]["compatibility_stdout_tail"] + rendered = render_update_plan_markdown(result) + if failed_stage: + assert "synthetic refusal" in rendered + assert "synthetic diagnostic" in rendered + if failed_stage == "compatibility": + assert "Rollback target authority check failed" in rendered + assert "cannot read current authority formats" not in rendered expected_release = current if failed_stage in {"compatibility", "doctor"} else target assert launcher.resolve() == expected_release / "scripts/loopx"