|
28 | 28 | DAEMON_SLEEP_CYCLE_STALE, |
29 | 29 | DAEMON_WATCHDOG_NEEDS_OPERATOR, |
30 | 30 | DAEMON_WEDGE_KILL, |
| 31 | + emit_best_effort, |
31 | 32 | write_event, |
32 | 33 | ) |
33 | 34 | from iai_mcp.identity_audit import continuous_audit |
@@ -282,9 +283,47 @@ def _store_is_empty(store: MemoryStore) -> bool: |
282 | 283 | # the unknown case as NOT empty so the tick proceeds; a truly empty store |
283 | 284 | # just does a little harmless no-op work. |
284 | 285 | log.debug("store empty check failed, assuming NOT empty: %s", exc) |
| 286 | + # e8f3deb fixed the *behavior* (don't park the tick) but left the |
| 287 | + # condition invisible. A recurring count failure (sqlite left in an error |
| 288 | + # state by a heavy reader) should surface to the operator, not just |
| 289 | + # log.debug. emit_best_effort is buffered and never raises, so it is safe |
| 290 | + # even when the store connection is the thing failing. |
| 291 | + try: |
| 292 | + emit_best_effort( |
| 293 | + store, |
| 294 | + "store_empty_check_failed", |
| 295 | + {"error": str(exc), "error_type": type(exc).__name__}, |
| 296 | + severity="warning", |
| 297 | + ) |
| 298 | + except Exception: # noqa: BLE001 -- telemetry must never break the tick |
| 299 | + pass |
285 | 300 | return False |
286 | 301 |
|
287 | 302 |
|
| 303 | +def _normalize_boot_lifecycle_state(raw: dict) -> tuple[dict, bool]: |
| 304 | + """Repair a crash-left incoherent lifecycle state at boot. |
| 305 | +
|
| 306 | + A daemon killed mid-SLEEP can leave lifecycle_state.json at |
| 307 | + current_state=SLEEP with sleep_cycle_progress=None -- incoherent, because a |
| 308 | + real in-flight sleep cycle always carries a progress dict. Resuming it wedges |
| 309 | + the daemon (it never advances the pipeline, never reaches the recluster that |
| 310 | + clears crisis, and recall stays degraded). Reset that one case to a clean |
| 311 | + WAKE and drop the stale crisis flag. A genuine degeneration re-arms crisis on |
| 312 | + the next complete sleep cycle. Returns (state, changed). |
| 313 | + """ |
| 314 | + if ( |
| 315 | + isinstance(raw, dict) |
| 316 | + and raw.get("current_state") == "SLEEP" |
| 317 | + and raw.get("sleep_cycle_progress") is None |
| 318 | + ): |
| 319 | + out = dict(raw) |
| 320 | + out["current_state"] = "WAKE" |
| 321 | + out["crisis_mode"] = False |
| 322 | + out["crisis_mode_since_ts"] = None |
| 323 | + return out, True |
| 324 | + return raw, False |
| 325 | + |
| 326 | + |
288 | 327 | def _is_inside_window( |
289 | 328 | window: tuple[int, int] | list | None, |
290 | 329 | now: datetime, |
@@ -1075,6 +1114,38 @@ async def _drain_and_report() -> None: |
1075 | 1114 | _sleep_pipeline = _SleepPipeline(store=store) |
1076 | 1115 |
|
1077 | 1116 | from pathlib import Path as _PathS2 |
| 1117 | + # Boot normalization for a crash mid-SLEEP: lifecycle_state.json can be |
| 1118 | + # left at current_state=SLEEP with sleep_cycle_progress=None -- an |
| 1119 | + # incoherent state (a real in-flight cycle always carries a progress |
| 1120 | + # dict). Resuming it wedges the daemon: it never advances the sleep |
| 1121 | + # pipeline, never reaches the recluster that clears crisis, and recall |
| 1122 | + # stays degraded (SLEEP + crisis both degrade recall). Reset that one |
| 1123 | + # case to a clean WAKE (and drop the stale crisis flag set before the |
| 1124 | + # crash) so the daemon serves immediately; a genuine degeneration will |
| 1125 | + # simply re-arm crisis on the next complete sleep cycle. |
| 1126 | + try: |
| 1127 | + import json as _json_lc |
| 1128 | + _lc_path = _PathS2.home() / ".iai-mcp" / "lifecycle_state.json" |
| 1129 | + _lc_raw = _json_lc.loads(_lc_path.read_text()) |
| 1130 | + _lc_norm, _lc_changed = _normalize_boot_lifecycle_state(_lc_raw) |
| 1131 | + if _lc_changed: |
| 1132 | + _lc_path.write_text(_json_lc.dumps(_lc_norm, indent=2)) |
| 1133 | + log.warning( |
| 1134 | + "lifecycle_boot_normalized: stale SLEEP without " |
| 1135 | + "sleep_cycle_progress -> WAKE (crisis cleared)" |
| 1136 | + ) |
| 1137 | + try: |
| 1138 | + emit_best_effort( |
| 1139 | + store, |
| 1140 | + "lifecycle_boot_normalized", |
| 1141 | + {"from_state": "SLEEP", "to_state": "WAKE", |
| 1142 | + "reason": "sleep_without_progress"}, |
| 1143 | + severity="warning", |
| 1144 | + ) |
| 1145 | + except Exception: # noqa: BLE001 -- telemetry must not block boot |
| 1146 | + pass |
| 1147 | + except (OSError, ValueError) as _lc_exc: |
| 1148 | + log.debug("lifecycle boot normalization skipped: %s", _lc_exc) |
1078 | 1149 | _s2_config = _load_s2_config() |
1079 | 1150 | _s2_coord = S2Coordinator( |
1080 | 1151 | store=store, |
|
0 commit comments