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: 3 additions & 1 deletion atlas/config/settings.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 44 additions & 13 deletions atlas/scheduler/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
44 changes: 44 additions & 0 deletions atlas/tests/test_upgrade_cycle.py
Original file line number Diff line number Diff line change
@@ -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
Loading