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
7 changes: 7 additions & 0 deletions docs/development/frontend-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion loopx/cli_commands/update_operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
50 changes: 50 additions & 0 deletions tests/test_update_operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import time
Expand Down Expand Up @@ -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
Expand Down
Loading