Skip to content

Commit 52ca300

Browse files
joelteplyclaude
andauthored
fix(persona): refuse a resident bootstrap when persistence is unconfirmed (#3927)
Resident memory survived a restart only by luck: the admission path could restore a persona without its checkpoint, and the CLI reported checkpoint state from a shape guess rather than the stored fact. - refuse a resident bootstrap when persistence cannot be confirmed, rather than admitting a persona with no restored memory - report checkpoint state from the typed stored fact, not an inferred shape - CoreProcessEvidence: missing/truncated process evidence and a live PID-file process must never authorize offline memory replacement (card 9f160b78) Dependency #3925 merged as 70ec392. Fresh CI on head 42f0a12 (run 34298594638) passed: 7,848 library tests, both compile-fail doctests, Windows library/test-harness checks, and the ts-rs binding drift detector. Card 6d17695c-a8a3-4df6-bd6e-b2172a41ee82. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4NU4VNiELPQfBpCacDZGc
1 parent ce1b59d commit 52ca300

5 files changed

Lines changed: 268 additions & 65 deletions

File tree

core/continuum-core/src/cognition/working_memory.rs

Lines changed: 89 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -324,28 +324,24 @@ pub enum WmKind {
324324
/// used to destroy (working memory, freshest full result, act fingerprints,
325325
/// the receipt counter). Written to `~/.continuum/personas/<id>/volatile.json`
326326
/// at shutdown / on tick write-through, restored at spawn. Deliberately
327-
/// EXCLUDES engrams (already durable in sqlite) and dispatched handles (their
328-
/// processes died with the old core — restoring them would fabricate
329-
/// in-flight work).
327+
/// EXCLUDES engrams (already durable in sqlite) and dispatched handles (the
328+
/// checkpoint cannot establish whether their operations are still in flight).
330329
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
331330
pub struct VolatileSnapshot {
332331
pub entries: Vec<WmEntry>,
333332
pub last_action: Option<(u64, String)>,
334333
pub action_fps: Vec<String>,
335334
pub next_action_seq: u64,
336-
/// Wall-clock when this snapshot was written — lets restore render the
337-
/// interruption GAP ("~N minutes ago") as a perceivable fact instead of
338-
/// an invisible discontinuity. `0` on snapshots from before this field
339-
/// (serde default): restore then omits the gap, never guesses it.
335+
/// Wall-clock when this snapshot was written — lets restore render its age,
336+
/// not the time of an interruption. `0` on snapshots from before this field
337+
/// (serde default): restore then reports the save time as unknown.
340338
#[serde(default)]
341339
pub saved_at_ms: u64,
342340
/// LABELS (never handles) of dispatched commands still `Running` at
343-
/// save time. Their processes die with the old core, so the handles are
344-
/// deliberately NOT restored (that would fabricate in-flight work) —
345-
/// but the persona must KNOW what was cut off so she can repeat it in
346-
/// one motion (Joel 2026-07-13: an interruption should be like closing
347-
/// a laptop — reopen, see what didn't finish, redo it easily). Restore
348-
/// renders these into a `[resumed]` fact marked safe-to-repeat.
341+
/// save time. Labels alone establish neither completion nor side effects,
342+
/// so restore reports those outcomes as unknown instead of restoring handles
343+
/// or asserting that repeating the operation is safe. The field name stays
344+
/// unchanged for compatibility with existing checkpoints.
349345
#[serde(default)]
350346
pub interrupted_dispatches: Vec<String>,
351347
/// Build the receipts in this snapshot were RECORDED against.
@@ -824,13 +820,10 @@ impl WorkingMemory {
824820
/// mid-thought instead of blank. Capacity re-clamps on the way in so a
825821
/// snapshot from a larger-capacity life never overflows this one.
826822
///
827-
/// The laptop-lid contract (Joel 2026-07-13): the interruption itself
828-
/// becomes a PERCEIVABLE fact — how long the lid was closed, and exactly
829-
/// which dispatched commands were cut off mid-flight (their processes
830-
/// died with the old core; the work did NOT complete and is safe to
831-
/// repeat). Without this, a killed dispatch is indistinguishable from a
832-
/// finished one, and she either re-does completed work or trusts work
833-
/// that never happened.
823+
/// The checkpoint becomes a PERCEIVABLE fact: its save age and the dispatch
824+
/// labels it recorded as pending. It does not establish when an interruption
825+
/// happened, whether those operations completed, or what side effects they
826+
/// produced. The persona decides what to do with that uncertainty.
834827
pub fn restore(&self, snap: VolatileSnapshot) {
835828
{
836829
let mut e = self.entries.lock();
@@ -877,10 +870,9 @@ impl WorkingMemory {
877870
self.next_action_seq
878871
.store(snap.next_action_seq.max(1), Ordering::Relaxed);
879872

880-
// Render the interruption as a fact AFTER the entries land, so it is
881-
// the NEWEST thing in her window when she wakes. A fact, never an
882-
// instruction — she decides whether the cut-off work still matters.
883-
let gap = (snap.saved_at_ms > 0)
873+
// Render the checkpoint evidence AFTER the entries land, so it is the
874+
// NEWEST thing in her window when she wakes. A fact, never an instruction.
875+
let checkpoint_age = (snap.saved_at_ms > 0)
884876
.then(|| {
885877
std::time::SystemTime::now()
886878
.duration_since(std::time::UNIX_EPOCH)
@@ -896,23 +888,21 @@ impl WorkingMemory {
896888
format!("~{mins} min")
897889
}
898890
});
899-
let fact = match (&gap, snap.interrupted_dispatches.is_empty()) {
900-
(Some(g), false) => format!(
901-
"[resumed] your session was interrupted {g} ago and your memory restored. Cut off mid-flight and NOT completed: {} — safe to repeat if still wanted",
902-
snap.interrupted_dispatches.join("; ")
903-
),
904-
(Some(g), true) => format!(
905-
"[resumed] your session was interrupted {g} ago and your memory restored; nothing was in flight"
906-
),
907-
(None, false) => format!(
908-
"[resumed] your session was interrupted and your memory restored. Cut off mid-flight and NOT completed: {} — safe to repeat if still wanted",
891+
let saved = match checkpoint_age {
892+
Some(age) => format!("saved {age} ago"),
893+
None => "with an unknown save time".to_string(),
894+
};
895+
let pending = if snap.interrupted_dispatches.is_empty() {
896+
"No pending dispatches were recorded in that checkpoint.".to_string()
897+
} else {
898+
format!(
899+
"Dispatches recorded as pending at that save: {}. Their completion and side effects are unknown.",
909900
snap.interrupted_dispatches.join("; ")
910-
),
911-
(None, true) => {
912-
"[resumed] your session was interrupted and your memory restored; nothing was in flight".to_string()
913-
}
901+
)
914902
};
915-
self.record_fact(&fact);
903+
self.record_fact(&format!(
904+
"[resumed] your memory was restored from a checkpoint {saved}. {pending}"
905+
));
916906

917907
// The OTHER discontinuity, and until 2026-08-07 an invisible one: the
918908
// substrate itself was rebuilt while she was away (#165).
@@ -1805,9 +1795,8 @@ mod tests {
18051795
// round-trips through the JSON snapshot losslessly: typed entries (kinds
18061796
// intact), the full last result, fingerprints, and the receipt counter
18071797
// (so post-restore receipts keep ascending numbers instead of colliding
1808-
// with restored ones) — PLUS the laptop-lid contract (Joel 2026-07-13):
1809-
// the interruption itself lands as the NEWEST fact, naming the gap and
1810-
// any dispatched commands cut off mid-flight as safe to repeat.
1798+
// with restored ones). The NEWEST fact names the checkpoint's save age and
1799+
// recorded pending labels without inventing interruption time or outcomes.
18111800
#[test]
18121801
fn volatile_snapshot_round_trips_and_renders_the_interruption() {
18131802
let wm = WorkingMemory::new(8);
@@ -1816,8 +1805,8 @@ mod tests {
18161805
wm.record_fact("[unfulfilled] I said I would run commands, but no tool ran");
18171806
wm.record_settlement("shared the plan");
18181807
wm.note_action_fingerprint("code/list|{\"path\":\".\"}");
1819-
// A dispatched compile still Running at snapshot time — the process
1820-
// dies with the old core; only its LABEL must survive.
1808+
// A dispatched compile still Running at snapshot time — only its LABEL
1809+
// survives, not evidence about later completion or side effects.
18211810
let handle = Uuid::new_v4();
18221811
wm.record_dispatch_event(
18231812
handle,
@@ -1845,14 +1834,27 @@ mod tests {
18451834
"window identical before the marker"
18461835
);
18471836
assert!(
1848-
resumed[0].contains("[resumed]"),
1849-
"interruption is perceivable: {resumed:?}"
1837+
resumed[0].starts_with("[resumed] your memory was restored from a checkpoint saved ")
1838+
&& resumed[0].contains(" ago."),
1839+
"the age describes the checkpoint save: {resumed:?}"
18501840
);
18511841
assert!(
1852-
resumed[0].contains("cargo build (dispatched)")
1853-
&& resumed[0].contains("safe to repeat"),
1854-
"cut-off work named + marked repeatable: {resumed:?}"
1855-
);
1842+
resumed[0].contains(
1843+
"Dispatches recorded as pending at that save: cargo build (dispatched)."
1844+
) && resumed[0].contains("Their completion and side effects are unknown."),
1845+
"pending work is named without inventing its outcome: {resumed:?}"
1846+
);
1847+
for unsupported in [
1848+
"was interrupted",
1849+
"NOT completed",
1850+
"safe to repeat",
1851+
"nothing was in flight",
1852+
] {
1853+
assert!(
1854+
!resumed[0].contains(unsupported),
1855+
"unsupported claim {unsupported:?}: {resumed:?}"
1856+
);
1857+
}
18561858
assert!(
18571859
!restored.iter().any(|l| l.contains("[rebuilt]")),
18581860
"SAME build across the restart — no rebuild fact, or we cry wolf on every \
@@ -1874,14 +1876,42 @@ mod tests {
18741876
"counter resumed: {last:?}"
18751877
);
18761878

1877-
// And the quiet path: nothing in flight → the fact says so plainly.
1879+
// Missing and zero save times remain unknown even with pending labels.
1880+
for missing_saved_at in [false, true] {
1881+
let mut legacy: serde_json::Value = serde_json::from_str(&json).expect("snapshot JSON");
1882+
if missing_saved_at {
1883+
let _ = legacy
1884+
.as_object_mut()
1885+
.expect("snapshot object")
1886+
.remove("saved_at_ms");
1887+
} else {
1888+
legacy["saved_at_ms"] = serde_json::json!(0);
1889+
}
1890+
let legacy = serde_json::from_value(legacy).expect("legacy snapshot deserializes");
1891+
let restored_legacy = WorkingMemory::new(8);
1892+
restored_legacy.restore(legacy);
1893+
let recent = restored_legacy.recent();
1894+
let resumed = recent.last().expect("restored checkpoint fact");
1895+
assert!(
1896+
resumed.contains("checkpoint with an unknown save time."),
1897+
"{resumed}"
1898+
);
1899+
assert!(!resumed.contains("ago"), "no fabricated save age: {resumed}");
1900+
assert!(resumed.contains("cargo build (dispatched)"), "{resumed}");
1901+
assert!(
1902+
resumed.contains("Their completion and side effects are unknown."),
1903+
"{resumed}"
1904+
);
1905+
}
1906+
1907+
// An empty list describes this checkpoint, not everything that was running.
18781908
let quiet = WorkingMemory::new(8);
18791909
quiet.restore(VolatileSnapshot {
18801910
entries: Vec::new(),
18811911
last_action: None,
18821912
action_fps: Vec::new(),
18831913
next_action_seq: 1,
1884-
saved_at_ms: 0, // pre-field snapshot: no gap guessed
1914+
saved_at_ms: 0, // pre-field snapshot: no save time guessed
18851915
interrupted_dispatches: Vec::new(),
18861916
build_sha: String::new(), // pre-field snapshot: no rebuild guessed either
18871917
receipt_heads: Vec::new(),
@@ -1890,10 +1920,15 @@ mod tests {
18901920
});
18911921
let q = quiet.recent();
18921922
assert_eq!(q.len(), 1);
1893-
assert!(q[0].contains("nothing was in flight"), "{q:?}");
1923+
assert!(
1924+
q[0].contains("No pending dispatches were recorded in that checkpoint."),
1925+
"{q:?}"
1926+
);
1927+
assert!(!q[0].contains("nothing was in flight"), "{q:?}");
1928+
assert!(q[0].contains("checkpoint with an unknown save time."), "{q:?}");
18941929
assert!(
18951930
!q[0].contains("ago"),
1896-
"no fabricated gap on legacy snapshots: {q:?}"
1931+
"no fabricated save age on legacy snapshots: {q:?}"
18971932
);
18981933
}
18991934

core/continuum-core/src/persona/host.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,9 @@ fn supervisor_error_facts(err: &SupervisorError) -> (Option<usize>, RoleId) {
713713
| SupervisorError::WorkspaceRegistration {
714714
slot_index, role, ..
715715
}
716+
| SupervisorError::AdmissionRestore {
717+
slot_index, role, ..
718+
}
716719
| SupervisorError::RuntimeMissing {
717720
slot_index, role, ..
718721
} => (Some(*slot_index), *role),

core/continuum-core/src/persona/service_loop.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5158,7 +5158,9 @@ mod tests {
51585158
defer_grounding: false,
51595159
suppress_recall: false,
51605160
};
5161-
crate::cognition::persona_workspace::global().register_from_cfg(cfg);
5161+
crate::cognition::persona_workspace::global()
5162+
.register_from_cfg(cfg)
5163+
.expect("test: register the held-work publication fixture");
51625164
let stub = StubAircCitizen::new(peer).with_claims(vec![held_card(peer)]);
51635165
let mut conversation = ScriptedConversation::new().with_citizen(Arc::new(stub));
51645166
if refuse_publication {

0 commit comments

Comments
 (0)