personal memory agent
0

Configure Feed

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

feat(backup): warn when offload has stalled

The warning carries current and threshold as None to keep the dict shape uniform for the post-phase consumer, which indexes both keys unconditionally behind a broad except. A missing key there would silently drop every storage warning.

Copy is imported from the backup copy module, never duplicated. merge_backup_config gives check_storage_health a pure seam with no journal I/O.

+227 -1
+6 -1
solstone/think/backup/state.py
··· 137 137 ) 138 138 139 139 140 + def merge_backup_config(config: dict[str, Any]) -> dict[str, Any]: 141 + return _merge_defaults(BACKUP_DEFAULTS, config.get("backup", {})) 142 + 143 + 140 144 def get_backup_config() -> dict[str, Any]: 141 145 config = read_journal_config() 142 - return _merge_defaults(BACKUP_DEFAULTS, config.get("backup", {})) 146 + return merge_backup_config(config) 143 147 144 148 145 149 def get_destination() -> Destination | None: ··· 489 493 "get_backup_config", 490 494 "get_destination", 491 495 "get_keys", 496 + "merge_backup_config", 492 497 "record_backup_result", 493 498 "record_offload_result", 494 499 "record_prune_result",
+22
solstone/think/retention.py
··· 29 29 from pathlib import Path 30 30 from typing import Any 31 31 32 + from solstone.apps.backup.copy import OFFLOAD_STALL_REASON_LABELS, OFFLOAD_STALLED_LEAD 33 + from solstone.think.backup.state import merge_backup_config 32 34 from solstone.think.data_state import DataState, derive_modality_state 33 35 from solstone.think.media import AUDIO_EXTENSIONS as RAW_AUDIO_EXTENSIONS 34 36 from solstone.think.media import MEDIA_EXTENSIONS as RAW_MEDIA_EXTENSIONS ··· 454 456 "threshold": raw_media_gb_threshold, 455 457 } 456 458 ) 459 + 460 + backup = merge_backup_config(config) 461 + offload = backup["offload"] 462 + last_offload = backup["last_offload"] 463 + if offload.get("enabled") is True and last_offload.get("status") == "stalled": 464 + reason = last_offload.get("reason") 465 + reason_label = ( 466 + OFFLOAD_STALL_REASON_LABELS.get(reason) if isinstance(reason, str) else None 467 + ) 468 + warnings.append( 469 + { 470 + "level": "warning", 471 + "type": "offload_stalled", 472 + "message": " ".join( 473 + part for part in (OFFLOAD_STALLED_LEAD, reason_label) if part 474 + ), 475 + "current": None, 476 + "threshold": None, 477 + } 478 + ) 457 479 458 480 return warnings 459 481
+29
tests/test_backup_state.py
··· 60 60 assert config["last_backup"] == state.BACKUP_DEFAULTS["last_backup"] 61 61 62 62 63 + def test_merge_backup_config_applies_defaults_to_raw_config() -> None: 64 + config = state.merge_backup_config( 65 + { 66 + "backup": { 67 + "offload": {"enabled": True}, 68 + "last_offload": { 69 + "status": "stalled", 70 + "reason": "backup_failing", 71 + }, 72 + } 73 + } 74 + ) 75 + 76 + assert config["offload"] == { 77 + "enabled": True, 78 + "budget_bytes": None, 79 + "floor_bytes": None, 80 + } 81 + assert config["last_offload"] == { 82 + "time": None, 83 + "status": "stalled", 84 + "reason": "backup_failing", 85 + "last_ok_time": None, 86 + "files_offloaded": 0, 87 + "bytes_offloaded": 0, 88 + "ran_out_of_media": False, 89 + } 90 + 91 + 63 92 def test_generate_and_store_keys_get_or_create( 64 93 tmp_path: Path, 65 94 monkeypatch: pytest.MonkeyPatch,
+84
tests/test_retention.py
··· 13 13 import pytest 14 14 from typer.testing import CliRunner 15 15 16 + from solstone.apps.backup.copy import ( 17 + OFFLOAD_STALL_REASON_LABELS, 18 + OFFLOAD_STALLED_LEAD, 19 + ) 16 20 from solstone.observe.processing_record import ( 17 21 FAILED_ATTEMPT_BOUND, 18 22 STATE_EMPTY, ··· 1166 1170 warnings = check_storage_health(summary, tmp_path, config=config) 1167 1171 assert len(warnings) == 1 1168 1172 assert warnings[0]["type"] == "raw_media_gb" 1173 + 1174 + @pytest.mark.parametrize( 1175 + ("backup", "expected_message"), 1176 + [ 1177 + ({}, None), 1178 + ( 1179 + { 1180 + "offload": {"enabled": False}, 1181 + "last_offload": { 1182 + "status": "stalled", 1183 + "reason": "backup_failing", 1184 + }, 1185 + }, 1186 + None, 1187 + ), 1188 + ( 1189 + { 1190 + "offload": {"enabled": True}, 1191 + "last_offload": {"status": "ok", "reason": None}, 1192 + }, 1193 + None, 1194 + ), 1195 + ( 1196 + { 1197 + "offload": {"enabled": True}, 1198 + "last_offload": { 1199 + "status": "stalled", 1200 + "reason": "backup_failing", 1201 + }, 1202 + }, 1203 + f"{OFFLOAD_STALLED_LEAD} {OFFLOAD_STALL_REASON_LABELS['backup_failing']}", 1204 + ), 1205 + ( 1206 + { 1207 + "offload": {"enabled": True}, 1208 + "last_offload": {"status": "stalled", "reason": None}, 1209 + }, 1210 + OFFLOAD_STALLED_LEAD, 1211 + ), 1212 + ( 1213 + { 1214 + "offload": {"enabled": True}, 1215 + "last_offload": { 1216 + "status": "stalled", 1217 + "reason": "unknown_reason", 1218 + }, 1219 + }, 1220 + OFFLOAD_STALLED_LEAD, 1221 + ), 1222 + ], 1223 + ) 1224 + def test_offload_stalled_warning_from_backup_config( 1225 + self, 1226 + tmp_path, 1227 + backup, 1228 + expected_message, 1229 + ): 1230 + config = { 1231 + "retention": { 1232 + "storage_warning_disk_percent": None, 1233 + "storage_warning_raw_media_gb": None, 1234 + }, 1235 + "backup": backup, 1236 + } 1237 + summary = self._make_summary() 1238 + 1239 + warnings = check_storage_health(summary, tmp_path, config=config) 1240 + 1241 + if expected_message is None: 1242 + assert warnings == [] 1243 + return 1244 + assert warnings == [ 1245 + { 1246 + "level": "warning", 1247 + "type": "offload_stalled", 1248 + "message": expected_message, 1249 + "current": None, 1250 + "threshold": None, 1251 + } 1252 + ] 1169 1253 1170 1254 def test_missing_retention_section_uses_defaults(self, tmp_path, monkeypatch): 1171 1255 """Missing retention section falls back to defaults (80% disk, null raw media)."""
+86
tests/test_think_segment_prephase.py
··· 474 474 assert daily_called 475 475 476 476 477 + def test_daily_postphase_emits_mixed_storage_warnings(journal_copy, monkeypatch): 478 + mod = importlib.import_module("solstone.think.thinking") 479 + callosum = importlib.import_module("solstone.think.callosum") 480 + retention = importlib.import_module("solstone.think.retention") 481 + sent = [] 482 + warnings = [ 483 + { 484 + "level": "warning", 485 + "type": "disk_percent", 486 + "message": "disk warning", 487 + "current": 95.0, 488 + "threshold": 80, 489 + }, 490 + { 491 + "level": "warning", 492 + "type": "offload_stalled", 493 + "message": "offload warning", 494 + "current": None, 495 + "threshold": None, 496 + }, 497 + ] 498 + 499 + def fake_daily(day, verbose, **kwargs): 500 + return (5, 0, [], set()) 501 + 502 + def fake_send(tract, event, **fields): 503 + sent.append((tract, event, fields)) 504 + return True 505 + 506 + _patch_main_runtime(monkeypatch) 507 + monkeypatch.setattr( 508 + mod, "run_bounded_phase", lambda cmd, day, timeout=None: (True, False) 509 + ) 510 + monkeypatch.setattr(mod, "run_command", lambda cmd, day: True) 511 + monkeypatch.setattr(mod, "run_queued_command", lambda cmd, day, timeout=600: True) 512 + monkeypatch.setattr(mod, "run_daily_prompts", fake_daily) 513 + monkeypatch.setattr(retention, "compute_storage_summary", lambda: object()) 514 + monkeypatch.setattr( 515 + retention, 516 + "check_storage_health", 517 + lambda summary, journal_path: warnings, 518 + ) 519 + monkeypatch.setattr(callosum, "callosum_send", fake_send) 520 + monkeypatch.setattr("sys.argv", ["sol think", "--day", "20240101"]) 521 + 522 + mod.main() 523 + 524 + storage = [item for item in sent if item[:2] == ("storage", "warning")] 525 + notification = [item for item in sent if item[:2] == ("notification", "show")] 526 + assert storage == [ 527 + ( 528 + "storage", 529 + "warning", 530 + { 531 + "level": "warning", 532 + "type": "disk_percent", 533 + "message": "disk warning", 534 + "current": 95.0, 535 + "threshold": 80, 536 + }, 537 + ), 538 + ( 539 + "storage", 540 + "warning", 541 + { 542 + "level": "warning", 543 + "type": "offload_stalled", 544 + "message": "offload warning", 545 + "current": None, 546 + "threshold": None, 547 + }, 548 + ), 549 + ] 550 + assert notification == [ 551 + ( 552 + "notification", 553 + "show", 554 + { 555 + "title": "Storage Warning", 556 + "message": "disk warning", 557 + "action": "/app/settings#storage", 558 + }, 559 + ) 560 + ] 561 + 562 + 477 563 def test_daily_segment_prephase_failure_has_no_timeout_reason( 478 564 journal_copy, monkeypatch 479 565 ):