Repository navigation
fix(meta): cached_completion write failures never cost the paid call (#269) - #270
Conversation
…269) - Hit path: count a hit only after the entry parsed to a dict with a str response; a corrupt / non-str / list entry is a miss (a list used to raise TypeError out of the call). - Write path: cache only str responses, encode before touching the disk, mkstemp + os.replace, swallow OSError, and always remove the *.partial temp sibling on failure. - Regression tests in tests/test_cached_completion.py.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Birfy
left a comment
There was a problem hiding this comment.
LGTM
Closes #269 cleanly and matches the encode-first / best-effort write contract from FileCache (#263/#264), TrajectoryStore (#259/#260), Ledger (#265/#266), and runstore (#267/#268) — with the important difference that OSError is swallowed here so a flaky cache never costs the paid completion.
(Self-approve blocked by GitHub; posting LGTM as COMMENT.)
What I verified
Hit path
- Entry is loaded first;
stats["hits"]increments only afterisinstance(entry, dict)andisinstance(cached, str). A truncated / wrong-shaped / list entry is a miss only (and only a miss). - A JSON list no longer raises
TypeErrorout of the call (was not covered by the oldexcept (OSError, ValueError, KeyError)). - Non-
strresponsevalues (null, int, missing key) are misses instead of being returned as hits and violatingprompt -> str.
Write path (_store)
- Only
strresponses are cached; anything else is returned uncached (paid call still returned to the caller). json.dumpsruns beforemkstemp, so an unencodable payload leaves no*.partial.mkstemp+ write blob +os.replace; anyOSErroris swallowed; response is still returned.tmp = Noneafter successful replace;finallyremoves the sibling on every failure path (secondaryOSErroron remove ignored).- Missing cache dir mid-run no longer raises after the paid call.
Design fit
- Same encode-first + always-clean-tmp shape as the recent on-disk cache fixes.
- Swallowing write
OSError(unlike runstore status writes) is the right contract for a memoisation layer in front of a paid LLM call — the cache is best effort; the response is not.
Tests
- New
tests/test_cached_completion.py: 13 collected. - Against
main(tests checked out on main code): 12 failed, 1 passed (only happy path). - On this branch (
a5668f0): 13 passed. - Related meta suite green locally:
test_meta+test_metasearch+test_metasearch_slots+test_metasearch_algotune. - Smoke:
test_cached_completion+test_evalcache+test_runstore_cligreen (Python 3.13).
Docs
- CHANGELOG Fixed entry accurately covers write-failure / no-partial / encode-first / corrupt-entry miss-only behaviour and cites #269.
CI
GitHub Actions matrix still pending at review time (build + test 3.9–3.13). Local coverage is solid for this change.
Non-blocking nits
- Secondary
OSErroronos.remove(tmp)is swallowed silently — same as FileCache / Ledger / runstore; fine for this contract. - After
isinstance(response, str), thejson.dumpsTypeError/ValueErrorpath is essentially unreachable for ordinary strings (belt-and-suspenders). Harmless and consistent with the other caches. - If
_storefails after a miss that rewrote a corrupt entry, the bad file remains and the next call pays again — correct best-effort behaviour; worth keeping in mind for long meta runs on a full disk.
No merge from this review. Ready once CI goes green.
Birfy
left a comment
There was a problem hiding this comment.
Automated review — LGTM ✅ (Closes #269)
Correctness
- Hit is counted only after the entry parsed to
dictwith astrresponse; fixes the double hit+miss on a truncated entry and the uncaughtTypeErroron a JSON-list entry (json.load(...)["response"]on a list). _storeencodes first (json.dumps), so an unencodable response never creates a temp file;OSErrorfrommkstemp/write/os.replaceis swallowed and the paid response is still returned;tmp = Noneafter a successful replace, sofinallyonly removes a temp that was not published.- Non-
strresponses are not cached — consistent with theprompt -> strcontract, and they cannot become "sticky" wrong-shaped hits. statskeys unchanged → existingtest_metasearch_slotsassertions hold.
Verification (local, Python 3 venv)
tests/test_cached_completion.py: 12/13 fail onmain, 13/13 pass on branch.test_cached_completion+test_metasearch_slots+test_meta+test_metasearch+test_metasearch_algotunegreen; full suite ran clean (no F/E).- CI: pending at time of review.
Nits (non-blocking)
- If
os.fdopenitself raised (practically never), the raw fd would leak; the temp file is still removed. Could wrap withos.close(fd)on that path. - A secondary
os.removefailure infinallyis silent — matches the FileCache/runstore precedent.
Closes #269
What
meta.cached_completionmemoises paid model calls on disk. Its write path now follows the same best-effort contract asFileCache(#263),TrajectoryStore(#259),Ledger(#265) andrunstore(#267): the cache can fail, the paid call cannot be lost.Hit path
dictwhoseresponseis astr. Before,stats["hits"] += 1ran beforejson.load, so a truncated entry was counted as both a hit and a miss.{"response": null},{"response": 7},{"other": ...}) is a miss instead of returning a non-strvalue. A JSON list entry used to raiseTypeErrorout of the call (it was not in theexcept); it is now a miss.Write path (new
_storehelper)strresponses are cached (the contract isprompt -> str); anything else is returned uncached.json.dumpsbefore touching the disk, so an unencodable response leaves no temp file.mkstemp+os.replaceas before; anyOSError(disk full, read-only dir, cache dir removed mid-run) is swallowed and the response is still returned.finallyremoves the*.partialsibling if the replace did not happen.statskeys are unchanged (hits,misses).Tests
New
tests/test_cached_completion.py(13 cases):*.partial, no entryos.replacefailure → no*.partialstrresponses (None, int, dict,object()) → returned uncached, not served from cachenull, int, list, missing key) → miss only, then rewritten and a real hit{"hits": 2, "misses": 1}Against
main: 12 of 13 fail (only the happy path passes). On this branch: all pass.tests/test_cached_completion.py+test_metasearch_slots.py+test_meta.py+test_metasearch.py+test_metasearch_algotune.pypass; full suite clean locally (no failures/errors).