core/chat/always_on.py
Shay 512453b6fc
feat(identity): split engine identity from build provenance (ADR-0220 PR C) (#774)
Removes code_revision from the engine-identity hash: engine_identity is now the
sha256 of the 5 ratified packs ONLY. The build revision is provenance (the
manifest's written_at_revision), not identity — so a behavior-neutral rebuild is
the SAME identity and the always-on daemon no longer flag-day strict-breaks on
every commit (the ADR-0220 defect).

Core:
- core/engine_identity.py: ratified_substrate/compute_engine_identity drop the
  git_revision arg (packs-only); add compute_legacy_engine_identity (reproduces
  the pre-split packs+rev hash, for migration verification), ENGINE_IDENTITY_SCHEME=2,
  IdentityReconciliation enum, and reconcile_loaded_identity — the single source of
  truth for the runtime guard AND the workbench reader.
- chat/runtime.py: the load guard reconciles via scheme. Current scheme -> direct
  packs-only compare. Legacy (code_revision-folded) stamp -> a VERIFYING migration:
  reconstruct the legacy hash from the persisted written_at_revision; a match proves
  packs unchanged (warn + re-stamp, resume, no break) — a mismatch means the packs
  genuinely changed (DIVERGED -> strict-refuse). Preserves 'distinct packs => refuse'.
- engine_state/save_manifest: stamp identity_scheme alongside engine_identity
  (additive-optional; no schema_version bump).
- workbench/readers.py: continuity reader uses the same reconcile (no phantom break
  on legacy checkpoints). cli.py break message reworded (packs, not 'build revision').

Callsite cleanup: drop the now-unused git_revision arg + imports across
always_on.py, evals/l10_always_on/runner.py, workbench/readers.py.

Tests:
- test_identity_provenance_split.py (new): 5 reconcile unit proofs + 3 runtime
  integration proofs incl. the wrong=identity defense (legacy stamp of DIFFERENT
  packs still strict-refuses) and the re-stamp migration.
- test_engine_identity.py: invert the code-revision test (rev no longer changes
  identity); assert the substrate is packs-only.
- Restore 3 lineage tests silently red since ADR-0219 (flat-path _manifest helper
  now resolves the gen-dir).
- Update remaining callsites/monkeypatches for the new signature.

Hygiene: .gitignore now covers the ADR-0219 gen-dir runtime files
(current, gen-*/, session_state.json, proposals.jsonl).

Verified: 59 identity/migration/lineage/workbench + 32 L10 + 76 invariants/cli +
34 smoke pass; serving lane SHAs unchanged (no derivation/reliability_gate touch).
2026-06-15 11:38:04 -07:00

279 lines
13 KiB
Python

"""The L10 always-on heartbeat — the loop that makes the life CONTINUOUS.
CORE is meant to be ONE continuous life (listen -> comprehend -> recall -> think ->
articulate -> learn -> replay), not "many lives sharing a checkpoint." Three pieces of
that spine are already built:
* the turn loop (``chat/runtime.py``) handles user turns;
* Shape B+ persistence makes a reboot resume the SAME life (field/vault/anchor/graph
restored bit-exactly, ``config.persist_session_state``);
* ``ChatRuntime.idle_tick`` advances continuous learning *between* turns
(proposal-only + sound session-memory consolidation).
What was missing is a runtime that holds the engine alive and learning over uptime with
no user turn — the T-experience direction. This module is the reusable **heartbeat
loop**: ``run_continuous`` ticks ``idle_tick`` on a cadence so the engine lives and
learns even when no one is talking to it, READS (never repairs) the closure invariant as
evidence each beat, and persists so the life survives interruption and resumes as the
SAME life. It is the core a daemon would call — the production daemon shell (a real
wall-clock cadence, signal handling, a ``core always-on`` CLI entry) is a thin follow-up
on top of this loop, not built here.
Safety by composition, no new authority:
* ``idle_tick`` is proposal-only (HITL ratification untouched) + sound, proof-gated
session-memory consolidation — the heartbeat introduces no unreviewed mutation.
* closure (``versor_condition < 1e-6``) holds BY CONSTRUCTION (the sanctioned session
anchoring; L10 Decision-0 ruling), so the heartbeat only *reads* ``versor_condition``
as telemetry — never a hot-path repair or watchdog (CLAUDE.md no-hot-path-repair).
* the engine's content identity is invariant within a life (config-derived, not
lived-state); cross-reboot "same life" enforcement is owned by the ``ChatRuntime``
load guard (``IdentityContinuityError`` when the stamped checkpoint identity differs
from the recomputed one) — this loop does not re-implement it.
See the L10 continuity soak (``evals/l10_continuity``) for the turn-loop half; this is
the idle/heartbeat half.
"""
from __future__ import annotations
import itertools
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from algebra.versor import versor_condition
from core.engine_identity import engine_identity_for_config
# The name of the persisted lived-life artifact (one per always-on run) in the
# engine-state dir, read-only, for the workbench Lived Life surface.
LIVED_LIFE_FILENAME = "lived_life.json"
LIVED_LIFE_SCHEMA_VERSION = "lived_life_v1"
# The non-negotiable field invariant (CLAUDE.md). The heartbeat READS this as evidence;
# it never repairs to keep it true — closure is owned by ``algebra/versor.py``.
CLOSURE_CEILING = 1e-6
# Granularity of the interruptible inter-beat wait. A daemon's clean-shutdown latency is
# bounded by THIS, not by the (possibly long) cadence interval — a SIGTERM mid-wait is
# honored within a slice instead of waiting out the whole interval.
_SLEEP_SLICE_SECONDS = 0.25
def _sleep_until_stop(seconds: float, stop: Callable[[], bool] | None) -> None:
"""Sleep up to ``seconds``, returning early the moment ``stop`` fires.
Without a ``stop`` predicate this is a plain ``time.sleep``. With one, the wait is
sliced so a long cadence interval never delays a clean shutdown beyond one slice."""
if stop is None:
time.sleep(seconds)
return
deadline = time.monotonic() + seconds
while not stop():
remaining = deadline - time.monotonic()
if remaining <= 0:
return
time.sleep(min(remaining, _SLEEP_SLICE_SECONDS))
@dataclass(frozen=True, slots=True)
class HeartbeatRecord:
"""Per-beat evidence of one continuous-life heartbeat (read-only telemetry)."""
tick: int
versor_condition: float | None # closure of the live field; None before any turn built one
field_valid: bool # versor_condition < CLOSURE_CEILING (vacuously True when no field yet)
facts_consolidated: int # Step-D facts learned this beat (continuous learning)
proposals_created: int # reviewable proposals emitted this beat (proposal-only)
pending_proposals: int
did_work: bool
frontier_findings: int = 0 # ADR-0080 SPECULATIVE findings mined this beat (0 unless enabled)
@dataclass(frozen=True, slots=True)
class AlwaysOnReport:
"""The ordered evidence of one always-on run — the soak subject.
``identity`` is the engine's content identity for the run (honest telemetry — it is
invariant within a life BY CONSTRUCTION, config-derived, so it is recorded once and
is NOT a continuity proof; cross-reboot "same life" is enforced by the ``ChatRuntime``
load guard). ``closure_observed`` says whether any beat actually observed a field, so
a consumer can distinguish "closure held over N observations" from "no field ever
existed" (``closure_held`` is vacuously True with zero observations).
``final_checkpoint_ok`` surfaces whether the exit checkpoint persisted (never silently
swallowed)."""
records: tuple[HeartbeatRecord, ...]
identity: str
closure_observed: bool
closure_held: bool # every OBSERVED versor_condition < CLOSURE_CEILING
final_checkpoint_ok: bool
total_facts_consolidated: int
total_proposals_created: int
total_frontier_findings: int = 0 # ADR-0080 SPECULATIVE findings mined across the run
# The identity-determining pack ids of the run's config, so a reader can recompute the
# SAME identity (the resume verdict) instead of assuming default packs. Empty = default.
identity_pack_ids: dict[str, str] = field(default_factory=dict)
@property
def heartbeats(self) -> int:
return len(self.records)
def serialize_report(report: AlwaysOnReport) -> dict[str, Any]:
"""Deterministic, JSON-able projection of an always-on run — the persisted lived-life
evidence the workbench reads."""
return {
"schema_version": LIVED_LIFE_SCHEMA_VERSION,
"identity": report.identity,
"heartbeats": report.heartbeats,
"closure_ceiling": CLOSURE_CEILING,
"closure_observed": report.closure_observed,
"closure_held": report.closure_held,
"final_checkpoint_ok": report.final_checkpoint_ok,
"total_facts_consolidated": report.total_facts_consolidated,
"total_proposals_created": report.total_proposals_created,
"total_frontier_findings": report.total_frontier_findings,
"identity_pack_ids": dict(report.identity_pack_ids),
"records": [
{
"tick": r.tick,
"versor_condition": r.versor_condition,
"field_valid": r.field_valid,
"facts_consolidated": r.facts_consolidated,
"proposals_created": r.proposals_created,
"pending_proposals": r.pending_proposals,
"did_work": r.did_work,
# Durable explanation of did_work when the beat's only work was a frontier
# mine (facts/proposals both 0) — else did_work has no persisted cause.
"frontier_findings": r.frontier_findings,
}
for r in report.records
],
}
def write_lived_life(report: AlwaysOnReport, path: Path) -> None:
"""Persist the lived-life evidence deterministically (sorted keys) so the workbench can
read it as a read-only artifact. Overwrites — the artifact is the latest always-on run
(a cumulative whole-life log is a future enhancement)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(serialize_report(report), indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _live_versor_condition(runtime) -> float | None:
"""Read the closure of the runtime's live field, or None if no turn built one yet.
Pure telemetry — never mutates or repairs the field."""
context = getattr(runtime, "_context", None)
state = getattr(context, "state", None) if context is not None else None
if state is None:
return None
return float(versor_condition(state.F))
def run_continuous(
runtime,
*,
heartbeats: int | None,
sleep_seconds: float = 0.0,
on_heartbeat: Callable[[HeartbeatRecord], None] | None = None,
stop: Callable[[], bool] | None = None,
report_path: Path | None = None,
) -> AlwaysOnReport:
"""Run the always-on heartbeat for up to ``heartbeats`` beats (``None`` = until ``stop``).
Each beat: advance continuous learning (``idle_tick``), then record the closure +
learning evidence. ``idle_tick`` self-checkpoints on real work; this loop also
checkpoints once at exit, so the life survives interruption — the next ``ChatRuntime``
over the same engine-state dir resumes the SAME life (with Shape B+ persistence on,
and the load-time identity guard enforcing it).
A finite ``heartbeats`` bounds a falsifiable soak; ``heartbeats=None`` runs unbounded
until ``stop`` fires — the daemon contract (``chat.always_on_daemon``), where ``stop``
is wired to SIGINT/SIGTERM. ``stop`` is checked BEFORE each beat AND interrupts the
inter-beat wait, so a clean shutdown is prompt and still persists the final state.
When ``report_path`` is given, the run's lived-life evidence is persisted there after
the loop exits — point it at ``<engine_state>/lived_life.json`` so the workbench Lived
Life surface reads the continuous life. A clean ``stop`` still writes the full report
(the report captures everything accumulated up to the stop). On a crash the engine
state is still checkpointed in ``finally`` for recovery, but the workbench report is
best-effort and simply not refreshed — the surface keeps the last good run.
"""
if heartbeats is not None and heartbeats < 0:
raise ValueError("heartbeats must be >= 0 or None")
config = runtime.config
identity = engine_identity_for_config(config)
# The identity-determining pack ids (empty == default) — persisted so a reader recomputes
# the SAME identity for the resume verdict instead of assuming default packs.
identity_pack_ids = {
"identity_pack": getattr(config, "identity_pack", "") or "",
"ethics_pack": getattr(config, "ethics_pack", "") or "",
"register_pack_id": getattr(config, "register_pack_id", "") or "",
"anchor_lens_id": getattr(config, "anchor_lens_id", "") or "",
}
records: list[HeartbeatRecord] = []
final_checkpoint_ok = True
try:
ticks = itertools.count() if heartbeats is None else range(heartbeats)
for tick in ticks:
if stop is not None and stop():
break
result = runtime.idle_tick()
vc = _live_versor_condition(runtime)
did_work = (
result.facts_consolidated > 0
or result.proposals_created > 0
or result.candidates_contemplated > 0
or getattr(result, "frontier_findings", 0) > 0
)
record = HeartbeatRecord(
tick=tick,
versor_condition=vc,
field_valid=(vc is None or vc < CLOSURE_CEILING),
facts_consolidated=result.facts_consolidated,
proposals_created=result.proposals_created,
pending_proposals=result.pending_proposals,
did_work=did_work,
frontier_findings=getattr(result, "frontier_findings", 0),
)
records.append(record)
if on_heartbeat is not None:
try:
on_heartbeat(record)
except OSError:
# Telemetry is best-effort like the checkpoint: a broken stderr/log
# pipe (BrokenPipeError) must NOT kill an indefinite-uptime life.
pass
if sleep_seconds:
_sleep_until_stop(sleep_seconds, stop)
finally:
# Final checkpoint even on a mid-beat interruption — the life persists and resumes
# as the SAME life. Best-effort so a checkpoint failure cannot mask the original
# error, but the outcome is SURFACED (final_checkpoint_ok), never silently swallowed.
try:
runtime.checkpoint_engine_state()
except Exception: # noqa: BLE001 — persistence is best-effort at the boundary
final_checkpoint_ok = False
observed = [r.versor_condition for r in records if r.versor_condition is not None]
report = AlwaysOnReport(
records=tuple(records),
identity=identity,
closure_observed=bool(observed),
closure_held=all(vc < CLOSURE_CEILING for vc in observed),
final_checkpoint_ok=final_checkpoint_ok,
total_facts_consolidated=sum(r.facts_consolidated for r in records),
total_proposals_created=sum(r.proposals_created for r in records),
total_frontier_findings=sum(r.frontier_findings for r in records),
identity_pack_ids=identity_pack_ids,
)
if report_path is not None:
write_lived_life(report, report_path)
return report