fix(models): refresh a stale ChatGPT plan roster at launch

Only three call sites refreshed the 24 h plan roster
(~/.codewhale/catalog/chatgpt-plan-<sha>.json): PKCE sign-in, the owned
login flow and an explicit `models --update`. A signed-in account that
never re-authenticated kept whatever it first fetched, so `codewhale
models --provider openai-codex` could report models that the account no
longer offers and never learn about new ones. The configured-provider
catalog sweep skips OpenaiCodex because provider_owns_live_catalog
excludes it, so nothing at launch repaired the roster.

spawn_refresh_if_stale runs the same fetch the manual path uses whenever
the roster is Missing, Stale or Invalid, from the launch block that
already refreshes the other three catalog layers. A Fresh roster costs
one blocking freshness probe and no request.

load_snapshot also returned an empty roster once past TTL, which is how a
stale cache turned into "no ChatGPT models at all" instead of a stale
list. Rows now survive staleness, matching Models.dev and provider
catalog behavior: freshness stays Stale, the preferred default stays
Fresh-only, and the new test asserts ids ["old"] with no preferred model.

Verified: ./scripts/dev-cargo.sh check -p codewhale-tui -p codewhale-config
(exit 0); test -p codewhale-tui codex_model_cache:: (5 passed). The stale
test was updated because the old empty-roster assertion encoded the bug.
Not verified here: a live launch against an expired cache.
This commit is contained in:
CodeWhale Bot committed 2026-10-23 01:02:50 -07:00
1 parent 853841506b
commit c869044358
2 files changed
+41 -5

No files matched your search

+40 -5
View File
@@ -204,10 +204,14 @@ fn load_snapshot(path: &Path, now: DateTime<Utc>) -> CodexModelRoster {
);
}
if age > MODEL_CACHE_MAX_AGE {
return CodexModelRoster::fallback(
CodexModelCacheFreshness::Stale,
Some(snapshot.fetched_at),
);
return CodexModelRoster {
models: snapshot.models,
freshness: CodexModelCacheFreshness::Stale,
fetched_at: Some(snapshot.fetched_at),
observed_at: None,
source: "chatgpt_plan_api",
observation_persisted: true,
};
}
CodexModelRoster {
models: snapshot.models,
@@ -296,6 +300,36 @@ pub(crate) async fn update_from_chatgpt(config: &Config) -> Result<CodexModelRos
.map_err(|_| "cache_write_failed")?
}
pub(crate) fn spawn_refresh_if_stale(config: &Config) {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
let config = config.clone();
handle.spawn(async move {
let probe = config.clone();
let Ok(freshness) =
tokio::task::spawn_blocking(move || model_roster_for(&probe).freshness).await
else {
return;
};
if freshness == CodexModelCacheFreshness::Fresh {
return;
}
match update_from_chatgpt(&config).await {
Ok(roster) => tracing::debug!(
target: "codex_model_cache",
model_count = roster.models.len(),
"ChatGPT plan roster refreshed"
),
Err(err) => tracing::debug!(
target: "codex_model_cache",
error = %err,
"ChatGPT plan roster refresh skipped"
),
}
});
}
fn read_cache_bytes(path: &Path) -> Result<Vec<u8>, CodexModelCacheFreshness> {
let path_metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
@@ -502,7 +536,8 @@ mod tests {
);
let stale = load_snapshot(&path, now);
assert_eq!(stale.freshness, CodexModelCacheFreshness::Stale);
assert!(stale.models.is_empty());
assert_eq!(stale.model_ids(), ["old"]);
assert_eq!(stale.preferred_model_id(), None);
save(
&path,
now + MAX_FUTURE_CLOCK_SKEW + Duration::seconds(1),
+1
View File
@@ -13675,6 +13675,7 @@ async fn run_interactive_prepared(
// not currently on are probed too; otherwise a relay's roster stays empty
// until the user switches to it.
crate::client::CodewhaleClient::spawn_configured_provider_catalog_refresh(config);
crate::codex_model_cache::spawn_refresh_if_stale(config);
// Boot janitors — snapshot prune (7-day default), spillover prune
// (#422), and managed-session cleanup (v0.8.44) — are best-effort disk