From 76a617ce232cf06000d6215bde7d7ea44b9fb171 Mon Sep 17 00:00:00 2001 From: Duang777 Date: Fri, 30 Oct 2026 01:01:17 +0800 Subject: [PATCH] fix(status): fail closed on unreadable retry journals Composition retry readback treated every OSError as an empty journal, hiding persisted pending work from status. Preserve missing-file behavior but raise a path-neutral error for other read failures, with a regression test covering both receipt and projection reads. Signed-off-by: Duang777 --- .../post_writeback_composition_retry.py | 6 ++- .../test_post_writeback_composition_retry.py | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/post_writeback_composition_retry.py b/loopx/control_plane/post_writeback_composition_retry.py index e10f9f5de2..999df916d6 100644 --- a/loopx/control_plane/post_writeback_composition_retry.py +++ b/loopx/control_plane/post_writeback_composition_retry.py @@ -188,8 +188,12 @@ def _iter_composition_retry_rows( try: lines = log_path.read_text(encoding="utf-8").splitlines() - except OSError: + except FileNotFoundError: return [] + except OSError as error: + raise RuntimeError( + "composition retry receipt journal is unreadable" + ) from error selected = lines if row_limit is None else lines[-max(0, row_limit) :] rows: list[dict[str, Any]] = [] for line in selected: diff --git a/tests/control_plane/test_post_writeback_composition_retry.py b/tests/control_plane/test_post_writeback_composition_retry.py index 7abf9694ce..4456e9fcad 100644 --- a/tests/control_plane/test_post_writeback_composition_retry.py +++ b/tests/control_plane/test_post_writeback_composition_retry.py @@ -538,6 +538,44 @@ def test_pending_receipts_skip_foreign_and_malformed_rows( ] +def test_pending_receipts_fail_closed_when_journal_is_unreadable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_root = tmp_path + journal_path = composition_retry_receipt_log_path(runtime_root, "goal-1") + receipt = build_composition_retry_receipt( + goal_id="goal-1", + event_kind="todo_complete", + identity=_identity(), + state_version="vision-revision-2", + committed_at="2026-09-06T00:00:00Z", + hook_identities=[{"hook_id": "h.one", "capability_id": "cap-one"}], + error_code="source_projection_failed", + ) + append_composition_retry_receipt(journal_path, receipt) + assert pending_composition_retry_receipts(runtime_root, "goal-1") == [receipt] + + real_read_text = Path.read_text + + def deny_journal_read( + path: Path, *args: object, **kwargs: object + ) -> str: + if path == journal_path: + raise PermissionError("injected journal read denial") + return real_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", deny_journal_read) + + with pytest.raises( + RuntimeError, match="composition retry receipt journal is unreadable" + ): + pending_composition_retry_receipts(runtime_root, "goal-1") + with pytest.raises( + RuntimeError, match="composition retry receipt journal is unreadable" + ): + collect_pending_composition_retry_projection(runtime_root, "goal-1") + + def test_journal_unavailable_degrades_to_identity_without_receipt_ref( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: