fix(catalog): skip byte-identical models-cache writes so a start cannot claim disk state changed - #5108
Conversation
|
✅ Deterministic PR hygiene checks passed. |
✅ READY
Review readiness checklist
✅ 4/4 boxes ticked. This pull request is already Ready for Review. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: lidge-jun/opencodex/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe change centralizes raw-byte comparison for prepared catalog writes. Active catalog and models cache synchronization now skip replacements when bytes are unchanged. Integration tests verify write results, file bytes, and modification times. ChangesCatalog write hardening
Priority: ⬇️ Low Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
89b587f to
5e2ff52
Compare
리뷰 · 우선순위 62 / 80
이(draft) PR은 다만 draft이고 경로 메인테이너의 판단이 필요한 지점
너의 추천 이 댓글은 grok-bot이 작성했습니다 |
…ot claim disk state changed
`invalidateCodexModelsCacheWithPermit` rewrote Codex's models cache
unconditionally, with no comparison against what was already on disk, and
returned `true` for having done so. `refreshCodexModelCatalog` reports that
boolean as `cacheSynced`, and `handleStart` ORs it into the stale-app-server
warning:
if (consumeStartupCacheInvalidationWrite() || startupSync.catalogWritten || startupSync.cacheSynced) {
warnIfStaleCodexAppServersAfterStartupWrite({ log: console });
}
So on a start where the catalog reproduced byte-identically — the settled case —
the warning asserted "Disk catalog/cache were updated" and told the operator
their Codex model list might be stale, for a write that never happened.
`writeRetainedCatalogSync` already skips a byte-identical catalog, so
`catalogWritten` is honest; `cacheSynced` was not, and it alone is enough to
raise the warning. That is the exact failure mode the catalog half of this rule
was added to stop.
This is the second writer that lidge-jun#1459 left uncovered. That issue fixed
`writeRetainedCatalogSync` with a byte-exact no-op guard, and its commit recorded
the independence deliberately — "cacheSynced is unaffected
(refreshCodexModelCatalog invalidates the models cache whenever the catalog
exists, independently of whether it was rewritten)". That is accurate: the
invalidation is attempted whenever the catalog exists, which is precisely why it
could report a write on a start that changed nothing.
The rule now lives once, in `preparedBytesDifferFromDisk`, and both writers
apply it. It stays a Buffer comparison rather than a decoded string for the
reason lidge-jun#1460 established: `readFileSync(path, "utf8")` substitutes U+FFFD for
every invalid byte, so a malformed file would decode equal to prepared content
holding a real U+FFFD, and the guard would preserve corruption while skipping
the atomic repair. An unreadable or absent file reports "differs" so the caller
performs the real write.
`pullRemoteCatalog` already guards this exact pair with a byte comparison and
returns `cacheSynced: false` when the bytes are unchanged
(`src/codex/catalog/remote.ts`), and `refreshCodexModelCatalog` returns
`cacheSynced: false` on both of its no-write paths with the comment "Invalidate
nothing". So the semantics were already established in this subsystem; only the
retained-sync writer had not been brought in line.
This also closes a latent defect in `pullRemoteCatalog`: it treats
`!cacheSynced` as a cache-sync failure and rolls the catalog back
(`restorePreviousCatalog` + a `write_failed` throw). Today that rollback cannot
fire, because the function returns early when the catalog bytes are unchanged, so
reaching the cache call implies the catalog changed and `cacheSynced` was
therefore reliably true. Once the cache writer reports a no-op honestly, the
rollback fires only for a genuine cache failure, which is what that branch is for.
`cacheSynced` now means what its name and its consumers already assume: a write
happened. The first pass of a change still writes, which is what keeps a guard
that simply refused every write from passing as this fix.
Refs lidge-jun#1459
ac3a539 to
afaaf9b
Compare
…art cannot claim disk state changed (#5108)" This reverts commit 0c45969. Three cases in tests/codex-integration/reserve-catalog-lifecycle.test.ts fail with catalogWritten false and cacheSynced false while catalogExists is true, which is exactly the skip path this change introduced. The child asserts result.cacheSynced, so a sync that finds the desired bytes already on disk now reports that the cache is not synced. The failure is order dependent, which is what makes reverting the right call rather than adjusting the assertion. It does not reproduce on the dev tip's own shard layout and appears once a neighbouring change shifts which tests share a shard, so the same landmine can go off on an unrelated branch at any time. The idea is sound and worth redoing: a start should not claim disk state changed when it did not. The redo has to separate the two meanings this change collapsed. "We wrote bytes" and "the cache holds the desired bytes" are different answers, and cacheSynced is read as the second one by src/cli/index.ts:627 and src/cli/dispatch.ts:455, which gate restart and reporting behaviour on catalogWritten or cacheSynced. Reporting false for an already-correct cache is what breaks, not the skipping itself. Co-authored-by: neerajdad123-byte <neerajdad123-byte@users.noreply.github.com>
Three Reserve catalog lifecycle cases fail on the dev tip's macOS shards with refreshOutcome committed, catalogExists true, catalogWritten false and cacheSynced false. The fixture's oracle required cacheSynced, and #5108 made that flag report whether bytes were written, so a sync that finds the desired bytes already on disk now answers false. The production meaning is the one to keep. src/cli/index.ts and src/cli/dispatch.ts gate restart and reporting on catalogWritten or cacheSynced, and a start that changed nothing should not claim it did. The stale assertion is the fixture's. Deleting the assertion is not available either: a broad catch inside the refresh also yields false, so dropping it would stop distinguishing a no-op from a failure. Instead the child now requires the committed verdict and then reads the cache back, proving the active catalog's slugs appear in the cache file in the same order. That is independent of the write flag and of the cache document shape, and every existing Reserve assertion is untouched. Root cause analysis by the contract-campaign task, which confirmed the genuine changed-source case still passes and only the byte-identical no-op trips the old oracle. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence.
) * refactor(codex,tests): split two files back under their size caps The file-size ratchet reported two offenders on dev. src/codex/history-provider.ts reached 2009 lines and is not in the baseline, so it tripped NEW_OVERSIZED at the 2000-line threshold. tests/server/server-combo-failover-e2e.test.ts reached 4192 against a committed cap of 4166. Both came from the batch merged just before: #5137 added the paginated-openai verdict to the history preflight, and #5142 added a tool-routing failover case. Raising either cap is not available. updateBaseline only ever lowers a cap and tests/ci-workflows/file-size-ratchet.test.ts asserts that, so a baseline edit would fail the same gate. Trimming the rationale comments those two changes carry would have fit the line budget and is the wrong trade: the comments are why the verdicts are readable at all. src/codex/history-rollout-read.ts takes the reading side of rollout JSONL files: the session_meta fold and the thread fields a quarantine restore reconstructs. The cut follows a seam rather than a line count. Everything moved reads a file and returns plain data; everything left behind writes a database or a backup manifest. history-provider.ts re-exports the public names, so no importer changes. tests/helpers/combo-tool-routing-cases.ts follows the register-cases seam the forced-effort, context-overflow and context-headroom groups already use in that file. The case body is unchanged; only its home moved, and it still runs inside the same describe with the same isolated homes, mocks and cleanup. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence. * test(codex): verify the Reserve cache on disk instead of the write flag Three Reserve catalog lifecycle cases fail on the dev tip's macOS shards with refreshOutcome committed, catalogExists true, catalogWritten false and cacheSynced false. The fixture's oracle required cacheSynced, and #5108 made that flag report whether bytes were written, so a sync that finds the desired bytes already on disk now answers false. The production meaning is the one to keep. src/cli/index.ts and src/cli/dispatch.ts gate restart and reporting on catalogWritten or cacheSynced, and a start that changed nothing should not claim it did. The stale assertion is the fixture's. Deleting the assertion is not available either: a broad catch inside the refresh also yields false, so dropping it would stop distinguishing a no-op from a failure. Instead the child now requires the committed verdict and then reads the cache back, proving the active catalog's slugs appear in the cache file in the same order. That is independent of the write flag and of the cache document shape, and every existing Reserve assertion is untouched. Root cause analysis by the contract-campaign task, which confirmed the genuine changed-source case still passes and only the byte-identical no-op trips the old oracle. No local suite, focused test, typecheck or build was run. Exact-head hosted CI is the execution evidence. --------- Co-authored-by: lidge-jun <lidge-jun@users.noreply.github.com>
Summary
Codex's models cache was rewritten unconditionally on every sync, with no comparison against what was already on disk, and the write was reported as
cacheSynced: trueregardless of whether any byte changed.The call chain.
ocx startends in this condition (src/cli/index.ts:622):refreshCodexModelCatalog(src/codex/refresh.ts:61) setscacheSyncedfrominvalidateCodexModelsCache, which reachesinvalidateCodexModelsCacheWithPermitand its unconditionalreplaceCodexModelsCache.The bug. Two files are written during a start: the catalog and the cache. The catalog writer was fixed in #1459 / #1460 — it compares bytes, skips an identical write, and reports
catalogWritten: false. The cache writer never got that treatment, socacheSyncedwas a constant rather than a signal: whenever the catalog file existed,refresh.ts:61set it totrue. That made the condition above permanently true, so a start that changed nothing still reported:and pointed the operator at
ocx sync --restart-codex, which ends live conversations. Nothing was stale, so the advice was not merely noisy — it was unactionable, because restarting cannot clear a staleness that does not exist. The identical rewrite also movedmodels_cache.json's mtime for no reason.catalogWrittencacheSyncedbeforecacheSyncedaftertruetruetruefalsetrue(bug)falsefalsefalsefalseThe fix. One guard, applied before the cache write, moved into
src/codex/internal/catalog-writer.tsso both writers share it and cannot drift:Identical bytes skip the write and report
false; different bytes write and reporttrue. An unreadable or absent file reports "differs", so the caller performs the real write and a missing file still converges.The comparison stays a
Bufferrather than a decoded string for the reason #1460 established:readFileSync(path, "utf8")substitutes U+FFFD for every invalid byte, so a malformed cache would compare equal to prepared content holding a real U+FFFD and the guard would preserve the corruption while skipping the atomic repair.writeRetainedCatalogSyncnow uses the same shared helper in place of #1460's private copy — no behaviour change there, it is a deduplication.Why this is a bug and not working as intended. The intended semantics are asserted by this subsystem's own code:
src/codex/catalog/remote.tsalready guards this same pair with a byte comparison and returnscacheSynced: falsewhen the bytes are unchanged.src/codex/refresh.ts:53-60returnscacheSynced: falseon both of its no-write paths, commented "Invalidate nothing: rewriting the models cache here would be exactly the routed-cache write the skip refused."tests/codex-integration/codex-models-cache-invalidate.test.ts:239— "Mirrorsocx sync --restart-codex: only handle app-servers after a real write."The eager invalidation itself was deliberate —
refreshCodexModelCatalog's docstring says the cache is forced stale. What went wrong is that only one of two writers received the guard, and once #1459 made the catalog honest,refresh.ts:61became the line that cancelled that fix out:cacheSyncedalone keeps the OR true, so #1459's carefully honestcatalogWrittennever reaches the condition.That commit recorded the gap explicitly:
That is accurate about the invalidation being attempted; the defect is that "attempted" was reported as "wrote".
One honest limit on the claim. The startup warning has a third input,
consumeStartupCacheInvalidationWrite(), fed by a separate cache write insrc/server/index.ts:275. When that one fires the message is arguably true. So this change does not make the warning always correct — it removes a permanently-true input so the warning reflects actual disk changes instead of firing on every start where the catalog exists.Adjacent effect.
pullRemoteCatalogtreats!cacheSyncedas a cache-sync failure and rolls the catalog back (restorePreviousCatalog+ awrite_failedthrow). That rollback cannot misfire from this change: the function returns early when the catalog bytes are unchanged, so reaching the cache call implies the catalog changed and the cache changes with it. The change only makes that branch mean what it says.Review readiness checklist
04761a188; branch is 0 commits behindlidge-jun:dev.)preparedBytesDifferFromDisk, carries a full JSDoc block.retained-sync.tsis a long-standing file whose functions are documented by inline reasoning comments rather than JSDoc, so bringing it to 80% is unrelated cleanup this change deliberately avoids.)Verification
bun test tests/codex-integration/codex-catalog-sync-hardening.test.ts tests/codex-integration/codex-catalog-writer.test.ts tests/codex-integration/codex-convergence-account-selectors.test.ts tests/codex-integration/codex-convergence-contract.test.ts→ 86 pass / 0 failbun test tests/codex-integration/codex-models-cache-invalidate.test.ts tests/codex-integration/codex-refresh.test.ts→ 17 pass / 0 fail (coversinvalidateCodexModelsCachereporting real write success and failure, and the#476 / #518write gate)bun test tests/codex-integration/codex-catalog-sync-hardening.test.ts tests/codex-integration/codex-refresh.test.ts tests/codex-integration/codex-catalog-writer.test.tson the rebased head → 45 tests, 44 pass / 1 fail, the single failure being "Gap B: drops legacy and unentitled account-gated natives…" hitting its 5000 ms per-test timeout while a full-suite run was still releasing resources in parallel. It passes in the 86-pass run above, where it completed in 2248 ms; the timeout was contention, not a regression.tsc --noEmit(strict) clean;privacy:scanpassedNew regression, "an identical cache resync leaves models_cache untouched, so the startup stale warning stays quiet": asserts the first pass writes (a bare catalog becomes Codex's cache wrapper), then that a second pass is skipped with the mtime unchanged and the bytes identical, and that
invalidateCodexModelsCachereturnsfalse. The first-pass assertion is deliberate — it fails if the guard is replaced by one that simply refuses every write, so the test cannot pass vacuously.One existing assertion was updated rather than loosened. "retired Spark cannot return through %s across two sync and cache passes" asserted
invalidated: trueon both passes while also asserting the second produced identical catalog bytes — a no-op it described as a write. It now asserts the no-op it describes (passes[0].invalidated === true,passes[1].invalidated === false), with the catalog and cache row checks underneath unchanged and still running over both passes.Checklist
docs-site/describes catalog sync behaviour, not this internal return value; the observable change is that a spurious warning stops appearing.)Refs #1459
Review readiness checklist
This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:
All CI tests are green on my local testing.
I pushed my PR to the latest dev commit.
I resolved all correct Codex and CodeRabbit findings.
My PR is ready for review.