diff --git a/core/cli.py b/core/cli.py index ea9989e0..d0e2af54 100644 --- a/core/cli.py +++ b/core/cli.py @@ -329,6 +329,61 @@ def cmd_chat(args: argparse.Namespace) -> int: _ALWAYS_ON_ALIVE_EVERY = 60 +def _always_on_identity_break_message( + engine_state_path: Path | None, exc: Exception +) -> str: + """Operator recovery guidance for an always-on identity-continuity refusal. + + Safe by construction (ADR-0220): it never tells the operator to ``mv``/``rm`` + the engine-state directory — under the default that directory IS the tracked + ``engine_state`` Python package, so moving it breaks ``import engine_state`` + across the codebase (including this very command). It steers to: re-run the + originating revision, a separate ``--engine-state`` dir, or clearing ONLY the + runtime files. The checkpoint revision is read from the manifest so option 1 + is copy-pasteable (placeholder when no readable manifest is present).""" + import warnings as _warnings + + from engine_state import EngineStateStore + + rev = "" + state_dir: Path | None = engine_state_path + try: + store = EngineStateStore(engine_state_path) + state_dir = store.path + with _warnings.catch_warnings(): + # Reading the manifest re-triggers ADR-0157's revision-mismatch + # RuntimeWarning; this is a message formatter, not a load path, so + # don't re-warn the operator as a side effect of building the string. + _warnings.simplefilter("ignore") + manifest = store.load_manifest() or {} + rev = str(manifest.get("written_at_revision") or rev) + except Exception: + # Best-effort: this is a display helper on an error path. If the manifest + # can't be read we degrade to the placeholder rev/dir — never let + # message-building mask the refusal that brought us here. + pass + return "\n".join( + [ + "this engine state belongs to a different life (substrate identity " + f"or build revision changed): {exc}", + "", + "Recovery options:", + " 1. Resume the old life — run the build that wrote it:", + f" git checkout {rev}", + " core always-on", + " 2. Start a fresh persisted life under a separate state dir " + "(leaves the old one untouched):", + " core always-on --engine-state ./engine_state_", + " 3. Start fresh in place — clear ONLY the runtime files in", + f" {state_dir or ''}", + " (manifest.json, the 'current' pointer + gen-*/ dirs, " + "recognizers.jsonl, discovery_candidates.jsonl,", + " session_state.json, proposals.jsonl) — never the directory " + "itself (it holds the engine_state package).", + ] + ) + + def cmd_always_on(args: argparse.Namespace) -> int: """Run the continuous-life heartbeat daemon (T-experience) until SIGINT/SIGTERM. @@ -383,6 +438,7 @@ def cmd_always_on(args: argparse.Namespace) -> int: try: result = run_daemon( config=daemon_config, + engine_state_path=getattr(args, "engine_state", None), interval=interval, max_beats=max_beats, no_load_state=bool(getattr(args, "no_load_state", False)), @@ -392,8 +448,9 @@ def cmd_always_on(args: argparse.Namespace) -> int: _die(f"always-on already running for this engine state: {exc}", code=2) except IdentityContinuityError as exc: _die( - "this engine state belongs to a different life (substrate identity " - f"changed): {exc}", + _always_on_identity_break_message( + getattr(args, "engine_state", None), exc + ), code=2, ) except IncompatibleEngineStateError as exc: @@ -4265,6 +4322,19 @@ def build_parser() -> argparse.ArgumentParser: "default: load + persist + resume the SAME life across restarts" ), ) + always_on.add_argument( + "--engine-state", + type=Path, + default=None, + metavar="PATH", + dest="engine_state", + help=( + "engine-state directory (the per-life state root) for this life; " + "default: $CORE_ENGINE_STATE_DIR or the in-repo engine_state/ dir. " + "Point at a dedicated path to run a separate persisted life without " + "touching another (and without moving the engine_state package dir)." + ), + ) always_on.add_argument( "--quiet", action="store_true", diff --git a/tests/test_l10_always_on_daemon.py b/tests/test_l10_always_on_daemon.py index 2670a007..32f882af 100644 --- a/tests/test_l10_always_on_daemon.py +++ b/tests/test_l10_always_on_daemon.py @@ -15,6 +15,7 @@ import subprocess import sys import threading import time +from pathlib import Path import pytest @@ -26,6 +27,11 @@ from chat.always_on_daemon import ( continuous_life_config, run_daemon, ) +from core.cli import ( + _always_on_identity_break_message, + build_parser, + cmd_always_on, +) from core.config import RuntimeConfig from workbench.lived_life import lived_life_from_payload @@ -210,3 +216,93 @@ def test_interruptible_sleep_without_stop_is_a_plain_sleep() -> None: start = time.monotonic() _sleep_until_stop(0.05, None) assert time.monotonic() - start >= 0.04 # actually waited (no stop predicate) + + +# --- PR B (ADR-0220): operator ergonomics — per-life state dir + safe recovery --- + + +def test_always_on_engine_state_flag_defaults_to_none() -> None: + # Default: fall back to $CORE_ENGINE_STATE_DIR / the in-repo dir (engine_state_path=None). + args = build_parser().parse_args(["always-on"]) + assert args.engine_state is None + + +def test_always_on_engine_state_flag_parses_to_path() -> None: + args = build_parser().parse_args(["always-on", "--engine-state", "/tmp/es_x"]) + assert args.engine_state == Path("/tmp/es_x") + + +def test_always_on_threads_engine_state_to_run_daemon(tmp_path, monkeypatch) -> None: + # The wiring obligation: --engine-state must reach run_daemon(engine_state_path=...), + # not be silently dropped. Capture the kwargs and short-circuit before the heartbeat. + import chat.always_on_daemon as daemon_mod + + class _Stop(Exception): + pass + + captured: dict = {} + + def _fake_run_daemon(**kwargs): + captured.update(kwargs) + raise _Stop() # stop before cmd_always_on's (un-mockable) summary epilogue + + monkeypatch.setattr(daemon_mod, "run_daemon", _fake_run_daemon) + target = tmp_path / "branch_life" + args = build_parser().parse_args( + ["always-on", "--engine-state", str(target), "--max-beats", "0"] + ) + with pytest.raises(_Stop): + cmd_always_on(args) + assert captured["engine_state_path"] == target + + +def test_identity_break_message_is_safe_and_revision_aware(tmp_path) -> None: + # The humane recovery message: revision-aware option 1, the --engine-state escape + # hatch, the underlying refusal preserved — and NEVER the mv/rm footgun (which would + # destroy the engine_state Python package under the default dir; ADR-0220). + state = tmp_path / "engine_state" + state.mkdir() + (state / "manifest.json").write_text( + json.dumps( + { + "schema_version": 2, + "turn_count": 7, + "written_at_revision": "deadbeefcafe", + "engine_identity": "a" * 64, + } + ), + encoding="utf-8", + ) + msg = _always_on_identity_break_message(state, RuntimeError("boom")) + assert "git checkout deadbeefcafe" in msg # copy-pasteable originating revision + assert "--engine-state" in msg # the safe per-life escape hatch + assert "boom" in msg # the original refusal is preserved + assert "mv engine_state" not in msg # the footgun is never suggested + assert "rm -rf engine_state" not in msg + + +def test_identity_break_message_without_manifest_uses_placeholder(tmp_path) -> None: + # No readable manifest -> placeholder revision, still structured, still safe, + # and never degrades the path line to a bare "None". + msg = _always_on_identity_break_message(tmp_path / "empty", RuntimeError("x")) + assert "" in msg + assert "mv engine_state" not in msg + assert "rm -rf engine_state" not in msg + assert "None" not in msg + + +def test_identity_break_message_handles_unresolvable_state_dir(monkeypatch) -> None: + # Edge: no --engine-state given AND store resolution fails -> the path line must + # fall back to a placeholder, never print a bare "None" (Gemini review nit). + import engine_state as es_mod + + def _boom(*_a, **_k): + raise RuntimeError("no store") + + # The helper does `from engine_state import EngineStateStore` at call time, so + # patching the module attribute reaches it. + monkeypatch.setattr(es_mod, "EngineStateStore", _boom) + msg = _always_on_identity_break_message(None, RuntimeError("x")) + assert "" in msg # path fell back, not None + assert "" in msg # rev fell back too + assert "None" not in msg