personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_activity_state_machine_durability.py
9.5 kB 250 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Durability tests for ActivityStateMachine snapshots.""" 5 6import json 7from pathlib import Path 8 9from solstone.think.activities import ( 10 append_activity_record, 11 load_activity_records, 12 make_activity_id, 13) 14from solstone.think.activity_state_machine import ActivityStateMachine 15from solstone.think.journal_io import atomic_replace 16 17 18def _sense(content_type: str = "coding", density: str = "active", facet: str = "test"): 19 return { 20 "density": density, 21 "content_type": content_type, 22 "activity_summary": f"{content_type} work", 23 "entities": [], 24 "facets": [{"facet": facet, "activity": content_type, "level": "high"}], 25 "meeting_detected": content_type == "meeting", 26 "speakers": [], 27 "recommend": {}, 28 } 29 30 31def _persist_snapshot(journal_root: Path, state_machine: ActivityStateMachine) -> None: 32 snapshot = { 33 "last_segment_key": state_machine.last_segment_key, 34 "last_segment_day": state_machine.last_segment_day, 35 "active": { 36 facet: {k: v for k, v in entry.items() if k != "_change"} 37 for facet, entry in state_machine.state.items() 38 }, 39 } 40 atomic_replace( 41 journal_root / "awareness" / "activity_state.json", json.dumps(snapshot) 42 ) 43 44 45def _append_ended_records( 46 state_machine: ActivityStateMachine, changes: list[dict], day: str 47) -> None: 48 completed_lookup = {} 49 for record in state_machine.get_completed_activities(): 50 completed_lookup.setdefault(record["id"], record) 51 for change in changes: 52 if change.get("state") != "ended": 53 continue 54 record = completed_lookup.get(change["id"]) 55 if record: 56 append_activity_record(change["facet"], day, record) 57 58 59def test_state_survives_subprocess_boundary(tmp_path: Path, monkeypatch): 60 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 61 62 sm1 = ActivityStateMachine(journal_root=tmp_path) 63 sm1.update(_sense(), "090000_300", "20260427") 64 assert set(sm1.state) == {"test"} 65 _persist_snapshot(tmp_path, sm1) 66 67 sm2 = ActivityStateMachine(journal_root=tmp_path) 68 assert sm2.last_segment_key == "090000_300" 69 assert sm2.last_segment_day == "20260427" 70 assert sm2.state["test"]["segments"] == ["090000_300"] 71 72 changes = sm2.update(_sense(density="idle"), "090500_300", "20260427") 73 ended = [change for change in changes if change.get("state") == "ended"] 74 assert len(ended) == 1 75 _append_ended_records(sm2, changes, "20260427") 76 _persist_snapshot(tmp_path, sm2) 77 78 records = load_activity_records("test", "20260427") 79 assert len(records) == 1 80 assert records[0]["id"] == make_activity_id("coding", "090000_300") 81 assert records[0]["segments"] == ["090000_300"] 82 83 sm3 = ActivityStateMachine(journal_root=tmp_path) 84 assert sm3.state == {} 85 assert sm3.last_segment_key == "090500_300" 86 assert sm3.last_segment_day == "20260427" 87 88 89def test_day_boundary_routes_ended_record_to_prior_day(tmp_path: Path, monkeypatch): 90 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 91 92 sm1 = ActivityStateMachine(journal_root=tmp_path) 93 sm1.update(_sense(), "233000_300", "20260304") 94 _persist_snapshot(tmp_path, sm1) 95 96 sm2 = ActivityStateMachine(journal_root=tmp_path) 97 routing_day = sm2.last_segment_day or "20260305" 98 changes = sm2.update(_sense(), "001500_300", "20260305") 99 100 ended = [change for change in changes if change.get("state") == "ended"] 101 active = [change for change in changes if change.get("state") == "active"] 102 assert len(ended) == 1 103 assert len(active) == 1 104 _append_ended_records(sm2, changes, routing_day) 105 _persist_snapshot(tmp_path, sm2) 106 107 prior_day_records = load_activity_records("test", "20260304") 108 current_day_records = load_activity_records("test", "20260305") 109 assert len(prior_day_records) == 1 110 assert current_day_records == [] 111 assert prior_day_records[0]["segments"] == ["233000_300"] 112 assert sm2.state["test"]["since"] == "001500_300" 113 assert sm2.last_segment_day == "20260305" 114 115 116def test_three_active_segments_then_idle_writes_one_record(tmp_path: Path, monkeypatch): 117 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 118 day = "20260427" 119 120 for segment in ("090000_300", "090505_300", "091010_300"): 121 state_machine = ActivityStateMachine(journal_root=tmp_path) 122 state_machine.update(_sense(facet="work"), segment, day) 123 _persist_snapshot(tmp_path, state_machine) 124 125 state_machine = ActivityStateMachine(journal_root=tmp_path) 126 routing_day = state_machine.last_segment_day or day 127 changes = state_machine.update( 128 _sense(density="idle", facet="work"), "091515_300", day 129 ) 130 ended = [change for change in changes if change.get("state") == "ended"] 131 assert len(ended) == 1 132 _append_ended_records(state_machine, changes, routing_day) 133 _persist_snapshot(tmp_path, state_machine) 134 135 records = load_activity_records("work", day) 136 assert len(records) == 1 137 assert records[0]["segments"] == ["090000_300", "090505_300", "091010_300"] 138 assert len(records[0]["segments"]) == 3 139 assert state_machine.state == {} 140 141 142def test_crash_between_append_and_snapshot_is_idempotent(tmp_path: Path, monkeypatch): 143 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 144 day = "20260427" 145 146 sm1 = ActivityStateMachine(journal_root=tmp_path) 147 sm1.update(_sense(facet="work"), "090000_300", day) 148 _persist_snapshot(tmp_path, sm1) 149 150 sm2 = ActivityStateMachine(journal_root=tmp_path) 151 routing_day = sm2.last_segment_day or day 152 changes = sm2.update(_sense(density="idle", facet="work"), "090505_300", day) 153 _append_ended_records(sm2, changes, routing_day) 154 assert len(load_activity_records("work", day)) == 1 155 156 sm3 = ActivityStateMachine(journal_root=tmp_path) 157 routing_day = sm3.last_segment_day or day 158 retry_changes = sm3.update(_sense(density="idle", facet="work"), "090505_300", day) 159 _append_ended_records(sm3, retry_changes, routing_day) 160 records = load_activity_records("work", day) 161 assert len(records) == 1 162 assert records[0]["id"] == make_activity_id("coding", "090000_300") 163 164 _persist_snapshot(tmp_path, sm3) 165 sm4 = ActivityStateMachine(journal_root=tmp_path) 166 assert sm4.state == {} 167 assert sm4.last_segment_key == "090505_300" 168 assert sm4.last_segment_day == day 169 170 171def test_batch_construction_has_no_journal_root_and_skips_snapshot( 172 tmp_path: Path, monkeypatch 173): 174 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 175 marker = { 176 "last_segment_key": "marker", 177 "last_segment_day": "20990101", 178 "active": {}, 179 } 180 state_path = tmp_path / "awareness" / "activity_state.json" 181 atomic_replace(state_path, json.dumps(marker)) 182 mtime_before = state_path.stat().st_mtime_ns 183 184 state_machine = ActivityStateMachine() 185 assert state_machine.journal_root is None 186 state_machine.update(_sense(facet="work"), "090000_300", "20260427") 187 state_machine.update(_sense(facet="work"), "090505_300", "20260427") 188 state_machine.update(_sense(density="idle", facet="work"), "091010_300", "20260427") 189 190 if state_machine.journal_root is not None: 191 _persist_snapshot(state_machine.journal_root, state_machine) 192 193 assert state_path.stat().st_mtime_ns == mtime_before 194 assert json.loads(state_path.read_text(encoding="utf-8")) == marker 195 196 197def test_run_segment_sense_emits_activity_events(tmp_path: Path, monkeypatch): 198 from solstone.think import thinking as think 199 200 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 201 day = "20260427" 202 stream = "default" 203 segment = "090505_300" 204 205 sm1 = ActivityStateMachine(journal_root=tmp_path) 206 sm1.update(_sense(facet="work"), "090000_300", day) 207 _persist_snapshot(tmp_path, sm1) 208 209 talents_dir = tmp_path / "chronicle" / day / stream / segment / "talents" 210 talents_dir.mkdir(parents=True) 211 (talents_dir / "sense.json").write_text( 212 json.dumps(_sense(density="idle", facet="work")), 213 encoding="utf-8", 214 ) 215 216 events = [] 217 monkeypatch.setattr( 218 think, 219 "get_talent_configs", 220 lambda schedule=None, **kwargs: { 221 "sense": {"priority": 10, "type": "generate", "output": "json"} 222 }, 223 ) 224 monkeypatch.setattr(think, "_dispatch_cortex_request", lambda **kwargs: "sense-1") 225 monkeypatch.setattr( 226 think, "_drain_priority_batch", lambda *args, **kwargs: (1, 0, []) 227 ) 228 monkeypatch.setattr( 229 think, "_jsonl_log", lambda event, **fields: events.append((event, fields)) 230 ) 231 monkeypatch.setattr(think, "run_activity_prompts", lambda **kwargs: True) 232 monkeypatch.setattr(think, "_callosum", None) 233 234 success, failed, failed_names = think.run_segment_sense( 235 day, 236 segment, 237 refresh=False, 238 verbose=False, 239 stream=stream, 240 state_machine=ActivityStateMachine(journal_root=tmp_path), 241 ) 242 243 assert (success, failed, failed_names) == (1, 0, []) 244 event_names = [event for event, _fields in events] 245 assert "activity.detected" in event_names 246 assert "activity.persisted" in event_names 247 detected = [fields for event, fields in events if event == "activity.detected"] 248 persisted = [fields for event, fields in events if event == "activity.persisted"] 249 assert detected[0]["change"] == "ended_idle" 250 assert persisted[0]["change"] == "ended_idle"