diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index bd6a2b88b9..55cd87f8ec 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -2299,6 +2299,13 @@ coverage; legacy runtime goals are extra. An unknown `--goal-id` is reported as the consumer rule are in the [projection envelope contract](reference/contracts/projection-envelope-contract.md). +An unreadable optional cache (including invalid UTF-8 or malformed JSON) is a +cache miss with `miss_reason=unreadable`, not a status-collection failure. +`status` and `quota should-run` collect live source facts instead. Reading does +not repair or delete the damaged cache; `--write-projection-cache` explicitly +replaces it with the newly collected projection. Canonical source errors still +retain their normal diagnostics and admission rules. + ## Decision Freshness Summary `decision_freshness_summary` is an optional checkpointed-decision projection over diff --git a/examples/control_plane/status-projection-cache-smoke.py b/examples/control_plane/status-projection-cache-smoke.py index dc3eedc8ff..9dd62ee90d 100644 --- a/examples/control_plane/status-projection-cache-smoke.py +++ b/examples/control_plane/status-projection-cache-smoke.py @@ -383,6 +383,35 @@ def main() -> int: assert_cache_expired(expired_quota) assert_next_action(expired_quota, successor_id) + # A damaged optional cache cannot hide the current canonical successor. + for previous, command in ( + (expired_status, ("status",)), + (expired_quota, ( + "quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_ID, "--available-capability", "shell", + )), + ): + path = Path(str(cache_metadata(previous)["path"])) + damaged = b'{"payload":"\xe2\x82' + path.write_bytes(damaged) + args = (*command, "--scan-path", str(public_doc), "--limit", "5") + recovered = run_cli(registry_path, *args, "--use-projection-cache") + assert recovered["ok"] is True, recovered + assert cache_metadata(recovered)["hit"] is False, recovered + assert cache_metadata(recovered)["miss_reason"] == "unreadable", recovered + assert successor_id in json.dumps(recovered), recovered + assert path.read_bytes() == damaged + if command[0] == "quota": + assert_next_action(recovered, successor_id) + + rewritten = run_cli( + registry_path, *args, "--use-projection-cache", "--write-projection-cache", + ) + assert_cache_written(rewritten) + reused = run_cli(registry_path, *args, "--use-projection-cache") + assert_cache_hit(reused) + assert successor_id in json.dumps(reused), reused + public_doc.write_text( f"Public leak probe must still be caught by default quota scan: {PRIVATE_DOC_MARKER}\n", encoding="utf-8", diff --git a/loopx/control_plane/runtime/status_projection_cache.py b/loopx/control_plane/runtime/status_projection_cache.py index 5555bf847d..5691a908b9 100644 --- a/loopx/control_plane/runtime/status_projection_cache.py +++ b/loopx/control_plane/runtime/status_projection_cache.py @@ -200,7 +200,7 @@ def load_status_projection_cache( return None, metadata try: cache_record = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: metadata["miss_reason"] = "unreadable" metadata["error"] = str(exc) return None, metadata diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index 013b463164..eba00ab5f2 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -5,6 +5,7 @@ teaches a reusable control-plane lesson. | Pattern | Symptoms | Evidence To Read | Likely Root | Durable Repair | | --- | --- | --- | --- | --- | +| `status_cache_decode_failure` | Opt-in cached status or quota readback fails with a UTF-8 decoding error while live collection works. | Cache miss metadata and a disposable malformed-cache reproduction through both CLI consumers. | The shared cache reader handles JSON and filesystem errors but lets text decoding abort live fallback. | Classify invalid UTF-8 as an unreadable cache miss in the existing IO owner; preserve damaged bytes on reads, collect live facts, and rewrite only with the explicit cache-write option. Test current Todo selection and successful reuse after repair; never suppress canonical source failures. | | `lock_holder_decode_hides_timeout` | Lock diagnostics or a contended acquisition raises `UnicodeDecodeError` instead of reporting unreadable metadata or the typed timeout. | Synthetic damaged holder bytes, liveness readback, and a real held-lock timeout followed by normal reacquisition. | Holder text decoding fails before the JSON/error guard, masking advisory failure and incident recording. | Treat invalid UTF-8 as unreadable at the shared holder reader; retain valid alternate records and kernel ownership. Cover the incident, inspection guidance and recovery after the actual holder exits; never delete the lock as repair. | | `scheduler_deadline_bucket_reset_gap` | A generated tighter deadline ACK fails with `progression_regression`, even after the host applies the requested RRULE. | Saved failed ACK, originating Turn, prior/current progression buckets and reset tokens, current host readback, and exact generated argv. | The deadline changes the effective progression while an override reset profile omits those buckets, so index zero still carries the old identity. | Bind effective buckets through the existing reset-profile digest; keep time within a bucket stable. Preserve native monotonic, host-readback, receipt-freshness, CAS and replay guards. Reproduce 30-minute/index-one to 15-minute/index-zero through disposable File/SQLite CLI and separately qualify the installed artifact; never edit active scheduler state or infer runtime adoption from source tests. | | `quiet_receipt_turn_start_context_loss` | A delegated request is delivered, but a quiet heartbeat reports no required inbox read and leaves it unassessed. | Original inbox delivery and assessment readback, initial hook dispatch, the captured final guard packet, and the automatic no-spend receipt reconstruction path. | The CLI observes pending context, then rebuilds the decision after stall writeback without forwarding that same Turn's hook dispatch. | Forward the original dispatch through the existing live-decision owner during reconstruction; preserve pending, empty and failed observations without re-running hooks or granting delivery/spend. Cover first and replayed stall receipts through final capture and CLI projection, including negative authority checks. Assess and link the original request, execute its admitted work, and report the actual result through its native return route; delivery or adoption alone is not completion. | diff --git a/tests/test_projection_envelope.py b/tests/test_projection_envelope.py index b61f348976..e4e1aabf81 100644 --- a/tests/test_projection_envelope.py +++ b/tests/test_projection_envelope.py @@ -597,6 +597,24 @@ def test_cached_status_keeps_observed_at_and_restamps_staleness(tmp_path: Path, assert "(cached)" in render_status_markdown(cached) +@pytest.mark.parametrize("content", [b"\xff", b'{"payload":"\xe2\x82', b'{"payload":']) +def test_unreadable_cache_is_a_non_mutating_miss(tmp_path: Path, content: bytes) -> None: + args = _cache_args(_registry(tmp_path)) + path = status_projection_cache_path( + args["runtime_root"], status_projection_cache_key(**args) + ) + path.parent.mkdir(parents=True) + path.write_bytes(content) + + cached, metadata = load_status_projection_cache(max_age_seconds=3600, **args) + + assert cached is None + assert metadata["hit"] is False + assert metadata["miss_reason"] == "unreadable" + assert metadata["error"] + assert path.read_bytes() == content + + def test_cache_entry_without_an_envelope_is_a_miss(tmp_path: Path) -> None: registry_path = _registry(tmp_path) args = _cache_args(registry_path)