diff --git a/loopx/control_plane/work_items/operator_inbox.py b/loopx/control_plane/work_items/operator_inbox.py index aba79c90e5..07efb76a0d 100644 --- a/loopx/control_plane/work_items/operator_inbox.py +++ b/loopx/control_plane/work_items/operator_inbox.py @@ -63,8 +63,11 @@ def _pending_events( ) -> list[dict[str, Any]]: processed_path = inbox / "processed.json" processed: set[str] = set() - if processed_path.is_file(): + try: processed_payload = _read_mapping(processed_path) + except FileNotFoundError: + pass + else: if ( processed_payload.get("schema_version") != source_contract.processed_schema_version diff --git a/loopx/extensions/lark/event_inbox.py b/loopx/extensions/lark/event_inbox.py index 3f8a429d52..453d78e1ae 100644 --- a/loopx/extensions/lark/event_inbox.py +++ b/loopx/extensions/lark/event_inbox.py @@ -239,9 +239,10 @@ def load_lark_event_inbox_config( def _load_processed(path: Path) -> set[str]: - if not path.is_file(): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: return set() - payload = json.loads(path.read_text(encoding="utf-8")) if ( not isinstance(payload, Mapping) or payload.get("schema_version") != PROCESSED_SCHEMA_VERSION @@ -726,12 +727,13 @@ def inspect_lark_event_inbox( def _material_review_ledger(inbox: Path) -> tuple[Path, dict[str, Any]]: path = inbox / "material-review" / "receipts.json" - if not path.is_file(): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: return path, { "schema_version": MATERIAL_REVIEW_LEDGER_SCHEMA_VERSION, "receipts": {}, } - payload = json.loads(path.read_text(encoding="utf-8")) if ( not isinstance(payload, dict) or payload.get("schema_version") != MATERIAL_REVIEW_LEDGER_SCHEMA_VERSION diff --git a/tests/extensions/test_operator_inbox_material_review.py b/tests/extensions/test_operator_inbox_material_review.py index c5501f9aae..2d05c8ea05 100644 --- a/tests/extensions/test_operator_inbox_material_review.py +++ b/tests/extensions/test_operator_inbox_material_review.py @@ -537,3 +537,56 @@ def blocked_writer(path: Path, payload: object) -> None: inbox = tmp_path / ".loopx" / "inbox" / "lark" / "material-review" processed = json.loads((inbox / "processed.json").read_text(encoding="utf-8")) assert set(processed["message_ids"]) == {"om_material_text", "om_direct"} + + +def test_processed_state_path_damage_never_reopens_acknowledged_events( + tmp_path: Path, +) -> None: + config = _config(tmp_path) + _ingest(tmp_path, config) + acknowledge_lark_event_inbox( + project=tmp_path, + config_path=config, + message_ids=["om_direct"], + execute=True, + ) + inbox = tmp_path / ".loopx" / "inbox" / "lark" / "material-review" + processed = inbox / "processed.json" + processed.unlink() + processed.mkdir() + + with pytest.raises(IsADirectoryError): + inspect_lark_event_inbox(project=tmp_path, config_path=config) + with pytest.raises(IsADirectoryError): + project_lark_event_inbox_urgency(project=tmp_path, config_path=config) + with pytest.raises(IsADirectoryError): + acknowledge_lark_event_inbox( + project=tmp_path, + config_path=config, + message_ids=["om_direct"], + ) + + +def test_material_review_receipt_path_damage_is_not_an_empty_ledger( + tmp_path: Path, +) -> None: + config = _config(tmp_path) + _ingest(tmp_path, config) + receipts = ( + tmp_path + / ".loopx" + / "inbox" + / "lark" + / "material-review" + / "material-review" + / "receipts.json" + ) + receipts.mkdir(parents=True) + + with pytest.raises(IsADirectoryError): + settle_lark_event_inbox_material_review( + project=tmp_path, + config_path=config, + message_id="om_material_text", + no_follow_up_reason="Reviewed before ledger damage.", + )