diff --git a/atlas/config/settings.example.json b/atlas/config/settings.example.json index 05db7d8..5510fde 100644 --- a/atlas/config/settings.example.json +++ b/atlas/config/settings.example.json @@ -63,7 +63,9 @@ "upgrade": { "approval_level": "PATCH_only", "cycle_schedule": "nightly@03:00", - "meta_recursion_every_n_cycles": 10 + "meta_recursion_every_n_cycles": 10, + "auto_apply": false, + "auto_restart": true }, "connectors": { "registry_path": "atlas/connectors/registry.json" diff --git a/atlas/scheduler/jobs.py b/atlas/scheduler/jobs.py index 17434b6..1b7f0e9 100644 --- a/atlas/scheduler/jobs.py +++ b/atlas/scheduler/jobs.py @@ -103,19 +103,50 @@ def purge_episodic() -> dict[str, Any]: def run_upgrade_cycle() -> dict[str, Any]: - """Nightly cycle (Section 3): check git for new ATLAS code and record whether - an update is available. Applying is owner-gated (the HUD's Update button) — - we never auto-apply unattended.""" - from ..engine.updater import check - info = check() - log_event("upgrade", { - "status": "checked", - "update_available": info.get("update_available", False), - "behind": info.get("behind", 0), - "current_version": info.get("current_version"), - }) - return {"status": "checked", "update_available": info.get("update_available", False), - "behind": info.get("behind", 0)} + """Nightly cycle (Section 3): check GitHub for new ATLAS code. If + `upgrade.auto_apply` is on, apply it (backup → health-check → auto-rollback); + if `upgrade.auto_restart` is on and a supervisor is present (ATLAS_SUPERVISED + via the LaunchAgent), exit so launchd respawns with the new code. Otherwise it + just records that an update is available for the Owner to apply from the HUD.""" + import os + from ..engine import updater + up = cfg.settings().get("upgrade", {}) or {} + info = updater.check() + if not info.get("update_available"): + log_event("upgrade", {"status": "up_to_date", "current_version": info.get("current_version")}) + return {"status": "up_to_date", "current_version": info.get("current_version")} + + if not up.get("auto_apply"): + log_event("upgrade", {"status": "update_available", "behind": info.get("behind", 0), + "auto_apply": False}) + return {"status": "update_available", "behind": info.get("behind", 0)} + + res = updater.apply(confirm=True) + log_event("upgrade", {"status": "applied" if res.get("applied") else "not_applied", + "applied": res.get("applied", False), + "rolled_back": res.get("rolled_back", False), + "to_version": res.get("to_version"), "detail": res.get("detail")}) + restarting = False + if res.get("applied") and up.get("auto_restart", True) and os.environ.get("ATLAS_SUPERVISED"): + _restart_for_update() + restarting = True + return {"status": "applied" if res.get("applied") else "failed", + "applied": res.get("applied", False), "rolled_back": res.get("rolled_back", False), + "to_version": res.get("to_version"), "restarting": restarting, "detail": res.get("detail")} + + +def _restart_for_update() -> None: + """Exit so the launchd supervisor (KeepAlive) respawns ATLAS with the new code. + Runs in a daemon thread after a short delay so the job result flushes first.""" + import os + import threading + import time + + def _bye() -> None: + time.sleep(2.0) + os._exit(0) # KeepAlive=true → launchd restarts us on the new code + + threading.Thread(target=_bye, daemon=True).start() def default_handlers() -> dict[str, Callable[[], Any]]: diff --git a/atlas/tests/test_upgrade_cycle.py b/atlas/tests/test_upgrade_cycle.py new file mode 100644 index 0000000..89ee67a --- /dev/null +++ b/atlas/tests/test_upgrade_cycle.py @@ -0,0 +1,44 @@ +"""L8 upgrade cycle: check-only vs auto-apply, gated by settings. Hermetic — the +updater and settings are monkeypatched, so nothing touches git or restarts.""" +from __future__ import annotations + +from atlas.engine import updater +from atlas.scheduler import jobs + + +def _settings(upgrade): + return lambda: {"upgrade": upgrade} + + +def test_up_to_date_is_a_noop(monkeypatch): + monkeypatch.setattr(jobs.cfg, "settings", _settings({"auto_apply": True})) + monkeypatch.setattr(updater, "check", lambda: {"update_available": False, "current_version": "0.4.0"}) + r = jobs.run_upgrade_cycle() + assert r["status"] == "up_to_date" + + +def test_checks_only_when_auto_apply_off(monkeypatch): + monkeypatch.setattr(jobs.cfg, "settings", _settings({"auto_apply": False})) + monkeypatch.setattr(updater, "check", lambda: {"update_available": True, "behind": 2, "current_version": "0.4.0"}) + applied = {"v": False} + monkeypatch.setattr(updater, "apply", lambda confirm: applied.update(v=True)) + r = jobs.run_upgrade_cycle() + assert r["status"] == "update_available" + assert applied["v"] is False # must NOT apply when the switch is off + + +def test_auto_applies_when_on(monkeypatch): + monkeypatch.setattr(jobs.cfg, "settings", _settings({"auto_apply": True, "auto_restart": False})) + monkeypatch.setattr(updater, "check", lambda: {"update_available": True, "behind": 1, "current_version": "0.4.0"}) + calls = {} + + def fake_apply(confirm): + calls["confirm"] = confirm + return {"applied": True, "to_version": "0.4.1"} + + monkeypatch.setattr(updater, "apply", fake_apply) + r = jobs.run_upgrade_cycle() + assert calls["confirm"] is True # applied with confirm=True + assert r["status"] == "applied" + assert r["to_version"] == "0.4.1" + assert r["restarting"] is False # not supervised in tests → no restart