core/chat/always_on_daemon.py
Shay 18e25580f3 feat(l10): always-on daemon/CLI — the process that runs the continuous-life heartbeat
The L10 heartbeat loop (run_continuous) had no process to drive it; `core always-on`
is that process — the T-experience spine made runnable. It ticks idle_tick on a wall-clock
cadence so the engine LIVES and LEARNS with no user turn, persists lived_life.json (the
workbench Lived Life surface) + the checkpoint, and resumes the SAME life on restart.

- chat/always_on.py: run_continuous gains unbounded operation (heartbeats=None, runs until
  stop) + an interruptible inter-beat wait (_sleep_until_stop) so shutdown latency is one
  slice, not the cadence interval. Persists the run's identity pack ids (resume-verdict
  faithfulness). on_heartbeat is now best-effort (a broken log pipe can't kill the life).
- chat/always_on_daemon.py (new): the daemon shell — a single-instance OS lock (fcntl.flock:
  kernel-held, atomic, auto-released on death — no stale window, no PID-reuse, no half-written
  race), SIGINT/SIGTERM -> graceful stop (handlers saved/restored), the continuous-life config
  FORCED on, ephemeral (--no-load-state) writes no durable artifact. Foreground + explicit:
  no hidden background execution (CLAUDE.md); only writes the engine-state dir it was given.
- core/cli.py: `core always-on [--interval --max-beats --no-load-state --quiet]` with per-beat
  + summary logging; validates --interval; reports IdentityContinuityError / IncompatibleEngine
  StateError (the "different life / newer build" cases) as clean refusals, not tracebacks.
- workbench/readers.py: ENGINE_STATE_ROOT now honors CORE_ENGINE_STATE_DIR (= the daemon's
  resolved dir), so the workbench can't be split-brained (reading REPO_ROOT/engine_state while
  the daemon writes elsewhere); the Lived Life resume verdict recomputes from the persisted
  pack config, not a default config (no false substrate_changed for a non-default-pack life).
- Lived Life absence state now points at the real `core always-on` command (loop closed).

Adversarial 4-lens review (lock/concurrency, signals/shutdown, invariants/trust-boundary,
test-vacuity/CLI) caught 16 findings; this fixes all real ones — the HIGH lock races (two
daemons over one life), the env split-brain, the IO-kill, the uncaught identity/schema errors,
the unvalidated interval, the ephemeral-artifact shadow, and the resume-verdict pack-id bug —
and closes the two test-coverage gaps it flagged (real SIGTERM path + config-forcing-at-the-
runtime boundary).

Tests (non-vacuous): 11 daemon (flock live-holder refusal, leftover-lock reclaim, unbounded+
stop, interruptible sleep, forced-config-at-boundary, no-load-state guard, REAL SIGTERM
subprocess) + the reader pack-id discrimination test (fails under the old default-config bug).
245 workbench+invariants+always-on Python green; frontend tsc + vitest green; `core always-on`
verified end-to-end (bounded, real SIGTERM graceful stop, interval rejection).

engine_state/always_on.lock is runtime state (gitignored, ADR-0146 pattern).
2026-06-14 17:33:37 -07:00

195 lines
7.8 KiB
Python

"""The always-on daemon shell — the thin process that RUNS the continuous-life heartbeat.
``chat/always_on.run_continuous`` is the reusable loop; this module is the daemon a
``core always-on`` invocation drives. It resolves the engine-state dir, takes a
single-instance lock (ONE life per engine-state dir — two daemons would corrupt the one
continuous life), installs SIGINT/SIGTERM handlers for a graceful stop, builds the
continuous-life ``ChatRuntime``, and runs the heartbeat unbounded until a signal — writing
the ``lived_life.json`` evidence the workbench reads and checkpointing so the next start
resumes the SAME life.
No new authority (CLAUDE.md): the heartbeat only ticks the proposal-only ``idle_tick`` and
READS ``versor_condition`` (no hot-path repair). It is FOREGROUND and explicit — there is
no hidden background execution; an operator backgrounds it with their shell / service
manager. The only writes are to the engine-state dir it was pointed at (the lock, the
manifest checkpoint, ``lived_life.json``).
"""
from __future__ import annotations
import dataclasses
import fcntl
import os
import signal
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from chat.always_on import (
LIVED_LIFE_FILENAME,
AlwaysOnReport,
HeartbeatRecord,
run_continuous,
)
from chat.runtime import ChatRuntime
from core.config import RuntimeConfig
from engine_state import EngineStateStore
LOCK_FILENAME = "always_on.lock"
# The continuous-life config: persist the lived field/vault across reboot (Shape B+),
# learn from determined facts each beat (Step D), and REFUSE to resume a different-identity
# checkpoint (the load-time identity guard). So a daemon restart is the SAME life or it
# stops — never a silent fork.
CONTINUOUS_LIFE_CONFIG_FLAGS: dict[str, Any] = {
"persist_session_state": True,
"consolidate_determinations": True,
"strict_identity_continuity": True,
}
class AlwaysOnLockedError(RuntimeError):
"""Another live always-on daemon already holds the lock for this engine-state dir."""
class _SingleInstanceLock:
"""Single-instance lock backed by an advisory OS lock (``fcntl.flock``).
The kernel holds the lock for the lifetime of the open fd and releases it AUTOMATICALLY
when the process dies — so there is no stale-lock window, no PID-reuse ambiguity, and no
empty / half-written-file race (a pid-file scheme has all three: a loser of the create
race can read a not-yet-written file and mistake a live holder for stale, then reclaim,
and two daemons proceed over one life). The lock FILE is intentionally never unlinked —
unlinking it would let a waiting peer flock a different inode — so the persistent marker
also carries the holder's pid for a human-readable refusal message.
"""
def __init__(self, path: Path) -> None:
self._path = path
self._fd: int | None = None
def _holder_pid(self) -> int | None:
try:
return int(self._path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
return None
def acquire(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self._path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
# The lock is held by another LIVE process (the kernel released it if the holder
# died). Read the marker pid for the message; it is informational only.
holder = self._holder_pid()
os.close(fd)
suffix = f" (pid {holder})" if holder is not None else ""
raise AlwaysOnLockedError(
f"another always-on daemon{suffix} holds {self._path}"
) from exc
# We hold the kernel lock; stamp our pid as a human-readable marker (best-effort —
# exclusion is the flock, not this content).
try:
os.ftruncate(fd, 0)
os.write(fd, f"{os.getpid()}\n".encode("utf-8"))
except OSError:
pass
self._fd = fd
def release(self) -> None:
if self._fd is None:
return
try:
fcntl.flock(self._fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(self._fd)
self._fd = None
@dataclass(frozen=True, slots=True)
class DaemonResult:
"""The outcome of one daemon run — the heartbeat report + how it ended."""
report: AlwaysOnReport
engine_state_path: Path
stopped_by_signal: bool
def continuous_life_config(base: RuntimeConfig | None = None) -> RuntimeConfig:
"""``base`` (or a default config) with the continuous-life flags forced on.
Forced, not defaulted: the daemon's whole purpose is ONE continuous life, so persistence
+ consolidation + the strict identity guard are not optional knobs here."""
cfg = base if base is not None else RuntimeConfig()
return dataclasses.replace(cfg, **CONTINUOUS_LIFE_CONFIG_FLAGS)
def run_daemon(
*,
config: RuntimeConfig | None = None,
engine_state_path: Path | None = None,
interval: float = 1.0,
max_beats: int | None = None,
no_load_state: bool = False,
on_record: Callable[[HeartbeatRecord], None] | None = None,
install_signals: bool = True,
stop_event: threading.Event | None = None,
) -> DaemonResult:
"""Run the always-on heartbeat as a daemon until a signal (or ``max_beats``).
Resolves the engine-state dir, takes the single-instance lock (raising
``AlwaysOnLockedError`` if a live daemon already owns this life), installs
SIGINT/SIGTERM handlers (unless ``install_signals=False`` — tests drive ``stop_event``
directly), then runs ``run_continuous`` with the continuous-life config forced on and
persists ``lived_life.json`` beside the checkpoint.
``install_signals=True`` must be called from the main thread (a Python signal-handler
constraint); tests pass ``install_signals=False`` with their own ``stop_event``.
"""
resolved = EngineStateStore(engine_state_path).path
cfg = continuous_life_config(config)
stop_event = stop_event if stop_event is not None else threading.Event()
signalled = threading.Event() # distinguishes a signal stop from a max_beats / test stop
lock = _SingleInstanceLock(resolved / LOCK_FILENAME)
lock.acquire() # fail fast BEFORE any signal/runtime setup if another life is live
previous_handlers: list[tuple[int, Any]] = []
try:
if install_signals:
def _handle(_signum: int, _frame: Any) -> None:
signalled.set()
stop_event.set()
for sig in (signal.SIGINT, signal.SIGTERM):
old = signal.getsignal(sig)
signal.signal(sig, _handle) # record only AFTER it actually changed
previous_handlers.append((sig, old))
runtime = ChatRuntime(
config=cfg, engine_state_path=resolved, no_load_state=no_load_state
)
# An ephemeral (no-load-state) life persists nothing — including no durable
# lived_life.json, which would otherwise overwrite the persistent life's artifact.
report_path = None if no_load_state else resolved / LIVED_LIFE_FILENAME
report = run_continuous(
runtime,
heartbeats=max_beats,
sleep_seconds=interval,
stop=stop_event.is_set,
on_heartbeat=on_record,
report_path=report_path,
)
finally:
for sig, handler in previous_handlers:
# getsignal can return None for a non-Python handler; restore SIG_DFL then.
signal.signal(sig, handler if handler is not None else signal.SIG_DFL)
lock.release()
return DaemonResult(
report=report, engine_state_path=resolved, stopped_by_signal=signalled.is_set()
)