From a95d991575f72e6f42d6424748e15d9ecb445143 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:18:30 +0530 Subject: [PATCH] fix(update): pin detached worker to its owning installation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/development/frontend-delivery.md | 7 ++++ loopx/cli_commands/update_operation.py | 10 +++++- tests/test_update_operation.py | 50 ++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 5f0260629b..d465a7604c 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -425,6 +425,13 @@ uv run --extra test python scripts/chat_bundle.py verify --source uv run --extra test loopx chat --no-open ``` +`loopx update apply` and `loopx upgrade` keep their detached worker on the +requesting installation, including when invoked inside another LoopX checkout. +The caller's working directory and explicit registry/runtime routes remain in +effect; a workspace or `PYTHONPATH` entry must not select another installation's +update code. An observer disconnect still leaves one durable worker and the +existing installation lock, status and result readback in control. + Builds start with current assets only. Use `build --previous /path/to/prior/chat` when deliberately carrying a prior local delivery; release and installation commands select their predecessor automatically. diff --git a/loopx/cli_commands/update_operation.py b/loopx/cli_commands/update_operation.py index 685752793a..a15670eef5 100644 --- a/loopx/cli_commands/update_operation.py +++ b/loopx/cli_commands/update_operation.py @@ -115,7 +115,15 @@ def start_or_observe(args: argparse.Namespace, registry_path: Path, *, registry_ os.chmod(folder / "worker.log", 0o600) options = {"start_new_session": True} if os.name != "nt" else { "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS} - child = subprocess.Popen([sys.executable, "-m", __name__, "--worker", operation_id], + # Keep relative source/state routes in the caller's cwd, but import + # the installation that admitted this update. Plain -m can load a + # different LoopX checkout from cwd before the installed package. + entry = ( + "import sys; sys.path.insert(0, sys.argv.pop(1)); " + "import runpy; runpy.run_module('loopx.cli_commands.update_operation', run_name='__main__')" + ) + child = subprocess.Popen([sys.executable, "-P", "-c", entry, + str(Path(__file__).resolve().parents[2]), "--worker", operation_id], stdin=subprocess.DEVNULL, stdout=log, stderr=log, **options) atomic_json(root / f"{key}.active.json", {"operation_id": operation_id, "pid": child.pid}) atomic_json(folder / "worker.json", {"pid": child.pid}) diff --git a/tests/test_update_operation.py b/tests/test_update_operation.py index a9d66a62af..d6924ff010 100644 --- a/tests/test_update_operation.py +++ b/tests/test_update_operation.py @@ -3,6 +3,7 @@ import argparse import json import os +from pathlib import Path import subprocess import sys import time @@ -124,6 +125,55 @@ def test_real_detached_worker_finishes_after_its_observer_exits(tmp_path): assert payload["result"]["plan"]["apply_supported"] is False +@pytest.mark.parametrize("command", ["update", "upgrade"]) +@pytest.mark.parametrize("shadow_pythonpath", [False, True]) +def test_detached_worker_keeps_its_installation_in_a_different_checkout( + tmp_path, monkeypatch, command, shadow_pythonpath, +): + """The requesting installation owns the worker, not the caller's workspace.""" + foreign = tmp_path / "another checkout" + shadow = foreign / "loopx" + shadow.mkdir(parents=True) + imported = tmp_path / "foreign-imported" + (shadow / "__init__.py").write_text( + f"from pathlib import Path\nPath({str(imported)!r}).touch()\n" + "raise RuntimeError('foreign installation must not run the update')\n" + ) + monkeypatch.chdir(foreign) + monkeypatch.setenv("LOOPX_UPDATE_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("LOOPX_BIN_DIR", str(tmp_path / "bin")) + monkeypatch.delenv("LOOPX_RELEASE_ROOT", raising=False) + # Exercise the real checkout's supported refusal, never an installed user's + # archive/package-manager mutation. The worker still runs the owning CLI. + own_root = Path(operations.__file__).resolve().parents[2] + monkeypatch.setenv("PATH", str(own_root / "scripts") + os.pathsep + os.environ["PATH"]) + if shadow_pythonpath: + monkeypatch.setenv("PYTHONPATH", str(foreign)) + else: + monkeypatch.delenv("PYTHONPATH", raising=False) + registry, runtime = tmp_path / "registry.json", tmp_path / "runtime" + if command == "upgrade": + registry, runtime = Path("registry.json"), Path("runtime") + args = argparse.Namespace(command=command, runtime_root=str(runtime), + timeout_seconds=30, repo=None, ref="main", archive_url=None, + source=True, allow_downgrade=False) + started = operations.start_or_observe(args, registry, registry_was_supplied=True) + operation_id = started["operation_id"] + for _ in range(100): + state = operations.read_operation(operation_id) + if imported.exists() or state["status"] not in {"starting", "running"}: + break + time.sleep(.1) + assert not imported.exists(), "the detached process imported the foreign checkout" + assert state["status"] == "failed", state + assert state["result"]["changes_applied"] is False + assert state["result"]["plan"]["apply_supported"] is False + request = json.loads((operations.operation_root() / operation_id / "request.json").read_text()) + argv = request["argv"] + assert argv[argv.index("--registry") + 1] == str(registry) + assert argv[argv.index("--runtime-root") + 1] == str(runtime) + + @pytest.mark.parametrize("switched", [True, False]) def test_material_failure_reports_an_observed_switch_but_never_invents_one(monkeypatch, switched): from loopx.self_update import execute_update_plan