Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion loopx/control_plane/post_writeback_composition_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 让目录访问失败到达这个 fail-closed 分支。调用方 collect_pending_composition_retry_projection 仍先调用 journal_path.is_file();Python 3.14 在父目录无访问权限时返回 false,随后跳过 reader。真实 File/SQLite 的 status 都继续 exit 0、ok=true 且无 pending receipt,恢复权限后原记录重现。请去掉该预检查或显式传播非缺失的 stat 错误,让同一 reader 区分缺失与不可读;增加真实 status 的目录拒绝与权限恢复回归。

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:
Expand Down
38 changes: 38 additions & 0 deletions tests/control_plane/test_post_writeback_composition_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading