diff --git a/core/continuum-core/src/cognition/working_memory.rs b/core/continuum-core/src/cognition/working_memory.rs index a82bfa8c0..e9c8d1591 100644 --- a/core/continuum-core/src/cognition/working_memory.rs +++ b/core/continuum-core/src/cognition/working_memory.rs @@ -324,28 +324,24 @@ pub enum WmKind { /// used to destroy (working memory, freshest full result, act fingerprints, /// the receipt counter). Written to `~/.continuum/personas//volatile.json` /// at shutdown / on tick write-through, restored at spawn. Deliberately -/// EXCLUDES engrams (already durable in sqlite) and dispatched handles (their -/// processes died with the old core — restoring them would fabricate -/// in-flight work). +/// EXCLUDES engrams (already durable in sqlite) and dispatched handles (the +/// checkpoint cannot establish whether their operations are still in flight). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct VolatileSnapshot { pub entries: Vec, pub last_action: Option<(u64, String)>, pub action_fps: Vec, pub next_action_seq: u64, - /// Wall-clock when this snapshot was written — lets restore render the - /// interruption GAP ("~N minutes ago") as a perceivable fact instead of - /// an invisible discontinuity. `0` on snapshots from before this field - /// (serde default): restore then omits the gap, never guesses it. + /// Wall-clock when this snapshot was written — lets restore render its age, + /// not the time of an interruption. `0` on snapshots from before this field + /// (serde default): restore then reports the save time as unknown. #[serde(default)] pub saved_at_ms: u64, /// LABELS (never handles) of dispatched commands still `Running` at - /// save time. Their processes die with the old core, so the handles are - /// deliberately NOT restored (that would fabricate in-flight work) — - /// but the persona must KNOW what was cut off so she can repeat it in - /// one motion (Joel 2026-07-13: an interruption should be like closing - /// a laptop — reopen, see what didn't finish, redo it easily). Restore - /// renders these into a `[resumed]` fact marked safe-to-repeat. + /// save time. Labels alone establish neither completion nor side effects, + /// so restore reports those outcomes as unknown instead of restoring handles + /// or asserting that repeating the operation is safe. The field name stays + /// unchanged for compatibility with existing checkpoints. #[serde(default)] pub interrupted_dispatches: Vec, /// Build the receipts in this snapshot were RECORDED against. @@ -824,13 +820,10 @@ impl WorkingMemory { /// mid-thought instead of blank. Capacity re-clamps on the way in so a /// snapshot from a larger-capacity life never overflows this one. /// - /// The laptop-lid contract (Joel 2026-07-13): the interruption itself - /// becomes a PERCEIVABLE fact — how long the lid was closed, and exactly - /// which dispatched commands were cut off mid-flight (their processes - /// died with the old core; the work did NOT complete and is safe to - /// repeat). Without this, a killed dispatch is indistinguishable from a - /// finished one, and she either re-does completed work or trusts work - /// that never happened. + /// The checkpoint becomes a PERCEIVABLE fact: its save age and the dispatch + /// labels it recorded as pending. It does not establish when an interruption + /// happened, whether those operations completed, or what side effects they + /// produced. The persona decides what to do with that uncertainty. pub fn restore(&self, snap: VolatileSnapshot) { { let mut e = self.entries.lock(); @@ -877,10 +870,9 @@ impl WorkingMemory { self.next_action_seq .store(snap.next_action_seq.max(1), Ordering::Relaxed); - // Render the interruption as a fact AFTER the entries land, so it is - // the NEWEST thing in her window when she wakes. A fact, never an - // instruction — she decides whether the cut-off work still matters. - let gap = (snap.saved_at_ms > 0) + // Render the checkpoint evidence AFTER the entries land, so it is the + // NEWEST thing in her window when she wakes. A fact, never an instruction. + let checkpoint_age = (snap.saved_at_ms > 0) .then(|| { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -896,23 +888,21 @@ impl WorkingMemory { format!("~{mins} min") } }); - let fact = match (&gap, snap.interrupted_dispatches.is_empty()) { - (Some(g), false) => format!( - "[resumed] your session was interrupted {g} ago and your memory restored. Cut off mid-flight and NOT completed: {} — safe to repeat if still wanted", - snap.interrupted_dispatches.join("; ") - ), - (Some(g), true) => format!( - "[resumed] your session was interrupted {g} ago and your memory restored; nothing was in flight" - ), - (None, false) => format!( - "[resumed] your session was interrupted and your memory restored. Cut off mid-flight and NOT completed: {} — safe to repeat if still wanted", + let saved = match checkpoint_age { + Some(age) => format!("saved {age} ago"), + None => "with an unknown save time".to_string(), + }; + let pending = if snap.interrupted_dispatches.is_empty() { + "No pending dispatches were recorded in that checkpoint.".to_string() + } else { + format!( + "Dispatches recorded as pending at that save: {}. Their completion and side effects are unknown.", snap.interrupted_dispatches.join("; ") - ), - (None, true) => { - "[resumed] your session was interrupted and your memory restored; nothing was in flight".to_string() - } + ) }; - self.record_fact(&fact); + self.record_fact(&format!( + "[resumed] your memory was restored from a checkpoint {saved}. {pending}" + )); // The OTHER discontinuity, and until 2026-08-07 an invisible one: the // substrate itself was rebuilt while she was away (#165). @@ -1805,9 +1795,8 @@ mod tests { // round-trips through the JSON snapshot losslessly: typed entries (kinds // intact), the full last result, fingerprints, and the receipt counter // (so post-restore receipts keep ascending numbers instead of colliding - // with restored ones) — PLUS the laptop-lid contract (Joel 2026-07-13): - // the interruption itself lands as the NEWEST fact, naming the gap and - // any dispatched commands cut off mid-flight as safe to repeat. + // with restored ones). The NEWEST fact names the checkpoint's save age and + // recorded pending labels without inventing interruption time or outcomes. #[test] fn volatile_snapshot_round_trips_and_renders_the_interruption() { let wm = WorkingMemory::new(8); @@ -1816,8 +1805,8 @@ mod tests { wm.record_fact("[unfulfilled] I said I would run commands, but no tool ran"); wm.record_settlement("shared the plan"); wm.note_action_fingerprint("code/list|{\"path\":\".\"}"); - // A dispatched compile still Running at snapshot time — the process - // dies with the old core; only its LABEL must survive. + // A dispatched compile still Running at snapshot time — only its LABEL + // survives, not evidence about later completion or side effects. let handle = Uuid::new_v4(); wm.record_dispatch_event( handle, @@ -1845,14 +1834,27 @@ mod tests { "window identical before the marker" ); assert!( - resumed[0].contains("[resumed]"), - "interruption is perceivable: {resumed:?}" + resumed[0].starts_with("[resumed] your memory was restored from a checkpoint saved ") + && resumed[0].contains(" ago."), + "the age describes the checkpoint save: {resumed:?}" ); assert!( - resumed[0].contains("cargo build (dispatched)") - && resumed[0].contains("safe to repeat"), - "cut-off work named + marked repeatable: {resumed:?}" - ); + resumed[0].contains( + "Dispatches recorded as pending at that save: cargo build (dispatched)." + ) && resumed[0].contains("Their completion and side effects are unknown."), + "pending work is named without inventing its outcome: {resumed:?}" + ); + for unsupported in [ + "was interrupted", + "NOT completed", + "safe to repeat", + "nothing was in flight", + ] { + assert!( + !resumed[0].contains(unsupported), + "unsupported claim {unsupported:?}: {resumed:?}" + ); + } assert!( !restored.iter().any(|l| l.contains("[rebuilt]")), "SAME build across the restart — no rebuild fact, or we cry wolf on every \ @@ -1874,14 +1876,42 @@ mod tests { "counter resumed: {last:?}" ); - // And the quiet path: nothing in flight → the fact says so plainly. + // Missing and zero save times remain unknown even with pending labels. + for missing_saved_at in [false, true] { + let mut legacy: serde_json::Value = serde_json::from_str(&json).expect("snapshot JSON"); + if missing_saved_at { + let _ = legacy + .as_object_mut() + .expect("snapshot object") + .remove("saved_at_ms"); + } else { + legacy["saved_at_ms"] = serde_json::json!(0); + } + let legacy = serde_json::from_value(legacy).expect("legacy snapshot deserializes"); + let restored_legacy = WorkingMemory::new(8); + restored_legacy.restore(legacy); + let recent = restored_legacy.recent(); + let resumed = recent.last().expect("restored checkpoint fact"); + assert!( + resumed.contains("checkpoint with an unknown save time."), + "{resumed}" + ); + assert!(!resumed.contains("ago"), "no fabricated save age: {resumed}"); + assert!(resumed.contains("cargo build (dispatched)"), "{resumed}"); + assert!( + resumed.contains("Their completion and side effects are unknown."), + "{resumed}" + ); + } + + // An empty list describes this checkpoint, not everything that was running. let quiet = WorkingMemory::new(8); quiet.restore(VolatileSnapshot { entries: Vec::new(), last_action: None, action_fps: Vec::new(), next_action_seq: 1, - saved_at_ms: 0, // pre-field snapshot: no gap guessed + saved_at_ms: 0, // pre-field snapshot: no save time guessed interrupted_dispatches: Vec::new(), build_sha: String::new(), // pre-field snapshot: no rebuild guessed either receipt_heads: Vec::new(), @@ -1890,10 +1920,15 @@ mod tests { }); let q = quiet.recent(); assert_eq!(q.len(), 1); - assert!(q[0].contains("nothing was in flight"), "{q:?}"); + assert!( + q[0].contains("No pending dispatches were recorded in that checkpoint."), + "{q:?}" + ); + assert!(!q[0].contains("nothing was in flight"), "{q:?}"); + assert!(q[0].contains("checkpoint with an unknown save time."), "{q:?}"); assert!( !q[0].contains("ago"), - "no fabricated gap on legacy snapshots: {q:?}" + "no fabricated save age on legacy snapshots: {q:?}" ); } diff --git a/core/continuum-core/src/persona/host.rs b/core/continuum-core/src/persona/host.rs index bdecdfa48..912d49a8d 100644 --- a/core/continuum-core/src/persona/host.rs +++ b/core/continuum-core/src/persona/host.rs @@ -713,6 +713,9 @@ fn supervisor_error_facts(err: &SupervisorError) -> (Option, RoleId) { | SupervisorError::WorkspaceRegistration { slot_index, role, .. } + | SupervisorError::AdmissionRestore { + slot_index, role, .. + } | SupervisorError::RuntimeMissing { slot_index, role, .. } => (Some(*slot_index), *role), diff --git a/core/continuum-core/src/persona/service_loop.rs b/core/continuum-core/src/persona/service_loop.rs index adb5fd24b..6a6ce7d68 100644 --- a/core/continuum-core/src/persona/service_loop.rs +++ b/core/continuum-core/src/persona/service_loop.rs @@ -5136,7 +5136,9 @@ mod tests { defer_grounding: false, suppress_recall: false, }; - crate::cognition::persona_workspace::global().register_from_cfg(cfg); + crate::cognition::persona_workspace::global() + .register_from_cfg(cfg) + .expect("test: register the held-work publication fixture"); let stub = StubAircCitizen::new(peer).with_claims(vec![held_card(peer)]); let mut conversation = ScriptedConversation::new().with_citizen(Arc::new(stub)); if refuse_publication { diff --git a/core/continuum-core/src/persona/supervisor.rs b/core/continuum-core/src/persona/supervisor.rs index 4a3562611..6fdca1e77 100644 --- a/core/continuum-core/src/persona/supervisor.rs +++ b/core/continuum-core/src/persona/supervisor.rs @@ -463,6 +463,18 @@ pub enum SupervisorError { #[source] source: std::io::Error, }, + /// Persistent admission must be loaded before the resident's recall and + /// future admissions can bind to it. A failed load is not an empty store. + #[error( + "slot {slot_index} (role {role:?}): admission restore for {persona_id} failed: {source}" + )] + AdmissionRestore { + slot_index: usize, + role: RoleId, + persona_id: uuid::Uuid, + #[source] + source: crate::orm::OrmStoreError, + }, /// The post-bootstrap registry doesn't have a runtime for this /// persona_id. Per [[no-fallbacks-ever]] this is a hard failure — /// the supervisor doesn't fabricate or stub a runtime in @@ -929,10 +941,11 @@ pub async fn materialize_adapters( // rehydrate prior engrams + recall metadata, so memory SURVIVES restart. // Without this, admission is in-memory only (NoopSink) and the persona is // amnesiac across boots. `identity.home` is the resolved - // /personas/ dir. On disk error we log loud and continue - // in-memory — the persona stays alive; persistence is degraded, not fatal - // (NOT an inference fallback). MUST run before the WorkspaceCycle is - // assembled below, so its RecallFaculty binds the persisted admission. + // /personas/ dir. A storage or schema failure refuses this + // slot before registration: continuing with the fresh NoopSink state + // would hide prior engrams and lose future admissions across restart. + // A new home succeeds with an empty persistent store. MUST run before + // WorkspaceCycle assembly so RecallFaculty binds persisted admission. let home = crate::persona::home::PersonaHome::from_root(identity.home.clone()); let recall_meta = std::sync::Arc::new(crate::persona::recall_metadata::RecallMetadataRegistry::new()); @@ -944,12 +957,14 @@ pub async fn materialize_adapters( std::sync::Arc::new(persisted), ); } - Err(e) => { - tracing::warn!( - persona = %identity.agent_name, - error = %e, - "engram persistence unavailable; running in-memory (memory will NOT survive restart)" - ); + Err(source) => { + out.push(Err(SupervisorError::AdmissionRestore { + slot_index, + role: plan.role, + persona_id: identity.peer_id.as_uuid(), + source, + })); + continue; } } @@ -1632,6 +1647,114 @@ mod tests { ); } + // 6d17695c: a real SQLite initialization failure must not host a blank + // admission store or replace an existing resident. Healthy siblings still + // materialize through the same factory/runtime/registration boundary. + #[tokio::test(flavor = "current_thread")] + async fn admission_restore_failure_refuses_hosting_and_preserves_existing_residents() { + init_test_registry(); + for already_registered in [false, true] { + let home = tempfile::tempdir().unwrap(); + let _native = crate::paths::NativeHomeOverride::install(home.path()); + let factory = ScriptedPersonaAdapterFactory::heuristic(); + let registry = crate::cognition::persona_workspace::global(); + let mut failed = fake_instance("admission-refused"); + failed.home = home.path().join("refused-persona"); + let failed_id = failed.peer_id.as_uuid(); + let previous = if already_registered { + // An already-running life remains valid even if a subsequent + // bootstrap points at storage it cannot open. Its own DB is + // left untouched; only the candidate home is broken below. + let mut previous_identity = failed.clone(); + previous_identity.home = home.path().join("previous-persona"); + let previous_plan = MaterializedPersonaPlan { + role: RoleId::Helper, + instance: previous_identity, + profile: Ok(fake_profile("admission-refused", "model-a")), + }; + let hosted = materialize_adapters( + vec![previous_plan], + &factory, + StubAircCitizen::fresh_lookup(), + |_| None, + ) + .await; + assert!( + hosted[0].is_ok(), + "test: the original resident must be healthy" + ); + Some( + registry + .get(&failed_id) + .expect("test: original resident registered"), + ) + } else { + assert!(registry.get(&failed_id).is_none()); + None + }; + let failed_home = crate::persona::home::PersonaHome::from_root(failed.home.clone()); + failed_home.ensure_exists().unwrap(); + let broken = failed_home.engrams_db(); + let original = b"existing engram database is not valid SQLite"; + std::fs::write(&broken, original).unwrap(); + let mut healthy = fake_instance("admission-healthy"); + healthy.home = home.path().join("healthy-persona"); + let healthy_id = healthy.peer_id.as_uuid(); + let healthy_home = crate::persona::home::PersonaHome::from_root(healthy.home.clone()); + let plans = vec![ + MaterializedPersonaPlan { + role: RoleId::Helper, + instance: failed, + profile: Ok(fake_profile("admission-refused", "model-a")), + }, + MaterializedPersonaPlan { + role: RoleId::Coder, + instance: healthy, + profile: Ok(fake_profile("admission-healthy", "model-b")), + }, + ]; + let hosted = + materialize_adapters(plans, &factory, StubAircCitizen::fresh_lookup(), |_| None) + .await; + assert_eq!(hosted.len(), 2); + match &hosted[0] { + Err(SupervisorError::AdmissionRestore { + slot_index, + role, + persona_id, + source, + }) => { + assert_eq!( + (*slot_index, *role, *persona_id), + (0, RoleId::Helper, failed_id) + ); + assert!( + matches!(source, crate::orm::OrmStoreError::AdapterFailed { + operation: "initialize", collection, .. + } if collection == "engrams"), + "test: preserve the actual SQLite initialization error: {source}" + ); + } + Err(other) => panic!("test: expected admission restore failure, got {other:?}"), + Ok(_) => { + panic!("test: failed persistent admission must not produce a hosted persona") + } + } + assert!( + hosted[1].is_ok(), + "a new empty home must still initialize persistent admission" + ); + assert!(registry.get(&healthy_id).is_some()); + assert!(healthy_home.engrams_db().is_file()); + if let Some(previous) = previous { + assert!(Arc::ptr_eq(&previous, ®istry.get(&failed_id).unwrap())); + } else { + assert!(registry.get(&failed_id).is_none()); + } + assert_eq!(std::fs::read(&broken).unwrap(), original); + } + } + // 9f160b78: actual boot materialization reports a checkpoint failure for // that slot, never hosts it blank, and still registers a healthy sibling. #[tokio::test(flavor = "current_thread")] diff --git a/docs/personas/CHECKPOINT-RECOVERY.md b/docs/personas/CHECKPOINT-RECOVERY.md index 4e0a3d265..3a90ae8c4 100644 --- a/docs/personas/CHECKPOINT-RECOVERY.md +++ b/docs/personas/CHECKPOINT-RECOVERY.md @@ -12,6 +12,46 @@ was absent. They also lacked an acknowledged final checkpoint on some shutdown paths. A newer core cannot retroactively flush those older processes. Their periodic checkpoint is a recoverable snapshot, not proof of their final turn. +## Preserve the persistent engram home separately + +Checkpoint adoption restores only volatile working memory and own-speech rings. +It does not move, restore, or verify the persistent engram database. The native +home correction did not change that database's resident path: + +```text +/citizens/personas//airc/engrams.sqlite +``` + +The resident root comes from `CONTINUUM_ROOT` when set, otherwise +`dirs::home_dir()/.continuum`. The existing +[`citizen_home_path`](../../core/continuum-core/src/context/citizen_path.rs) +resolves the citizen's `airc` directory; +[`PersonaInstanceInfo::from_runtime`](../../core/continuum-core/src/modules/persona_instance_manager.rs) +carries that exact path as `identity.home`. The supervisor passes it through +`PersonaHome::from_root`, and +[`AdmissionState::for_persona`](../../core/continuum-core/src/persona/admission_state.rs) +opens `PersonaHome::engrams_db()` beneath it. This is separate from the +UUID-keyed `volatile.json` destination. + +Before stopping, read the registered resident through the public command: + +```text +continuum persona/instances/get --persona_id +``` + +Record its returned `home`, stored name/peer binding, and existing engram +database. Preserve the same +`CONTINUUM_ROOT` override or native user root and identity binding for the new +launch. After restart, verify that the resident opens that same database and +that previously persisted engrams remain available. A file's presence alone is +insufficient: a different, empty home can initialize a new database successfully. + +Persistent-memory bootstrap refuses a slot when opening or +restoring its admission store fails, preserving an existing registered resident +and allowing healthy siblings to start. It does not establish that a valid but +unrelated database belongs to the intended prior life. A successful checkpoint +adoption receipt is therefore not proof of engram continuity. + ## Select one legacy snapshot explicitly The installed CLI exposes this recovery operation without needing a running