From 16f49f8684b6ef92ebb4a2e53de787a7adc2d7a5 Mon Sep 17 00:00:00 2001 From: Yuchao Wang Date: Sun, 18 Oct 2026 07:15:13 +0000 Subject: [PATCH] fix(status): fall back to live facts for undecodable caches Signed-off-by: Yuchao Wang --- docs/status-data-contract.md | 7 +++++ .../status-projection-cache-smoke.py | 29 +++++++++++++++++++ .../runtime/status_projection_cache.py | 2 +- .../references/repair-patterns.md | 1 + tests/test_projection_envelope.py | 18 ++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) 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 9ae06b7f2a..c788c38da1 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. | | `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. | | `benchmark_planning_clock_projection_gap` | A resumed task closes work using an earlier Host deadline even though a continuation was explicitly authorized; readable history and an old ready plan are mistaken for current executable work. | Original task constraints, Host phase receipts, saved model input, current authorized continuation, actual quota/frontier readback and terminal dispositions; keep private trajectories outside public artifacts. | Historical Host timing and closure context survived recovery without the new continuation intent; ordinary planning should not inject Host countdowns. | Keep Host deadlines, remaining time and execution reserves out of ordinary model input; retain external enforcement and task-owned time constraints. At the explicitly authorized archive-recovery boundary, preserve old history and closure records, identify earlier Host clocks as historical in the new continuation request, and reuse Goal/Todo/replan with a stable operation identity. Read back actionable work or review before calling continuation ready; an old plan or exit code zero is insufficient. Do not renew budgets on retry, reopen without authorization, clear a user stop, filter task deadlines or bypass evaluation cooldowns. Cover clock-free fresh/resumed input, idempotent recovery and legitimate stopping; qualify real model adoption and score gains separately. | | `cli_json_file_argument_contract_gap` | A `--*-json` option repeatedly rejects inline JSON as an unreadable object; reshaping it does not help, or Markdown shows only an invalid-request status. | The command's current help, file-input loader, structured error, Markdown renderer, and a synthetic valid-file recovery. | The option accepts a file path but help does not say so; file-read, JSON-decode and root-shape failures are conflated, or the renderer omits the error reason. | Clarify the existing file contract without silently adding inline input. Separate read, parse and shape diagnostics without echoing supplied contents or paths; preserve the reason in each output format. Validate missing/inline input, malformed/non-object files, same-path recovery and unchanged typed admission. Repair the owning CLI adapter, not provider readiness or Goal retry authority. | 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)