fix(runner): reject a spawn model missing from the machine's catalog - #1753
Open
heavygee wants to merge 1 commit into
Open
fix(runner): reject a spawn model missing from the machine's catalog#1753heavygee wants to merge 1 commit into
heavygee wants to merge 1 commit into
Conversation
Spawning with a model the agent does not have (`--agent cursor --model gpt-5`) booted a child that died at the agent handshake, leaving an archived session with zero turns and no explanation anywhere in the UI. Add a runner preflight next to the agent-availability check: when the machine already has a cached model catalog for the flavor, an id that is not in it fails the spawn with `model_unavailable` and a message naming the accepted ids (near-misses first). Cursor is the only flavor with a catalog readable without a probe today; every other flavor reports an empty catalog, which the validator treats as "unknown" and never rejects — a spawn must not block on `agent --list-models`, which can take 30s and contends with the ACP spawn lease. Fixes tiann#1752 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Cache reads can make an arbitrarily stale catalog look fresh - the new preflight trusts the shared file's mtime at
cli/src/modules/common/cursorModels.ts:399, but runner startup callsscheduleCursorModelsPrewarm()(cli/src/runner/run.ts:1202). When a shared cache exists,listCursorModels()loads it atcli/src/modules/common/cursorModels.ts:335andapplyInMemoryCache()writes the same catalog back atcli/src/modules/common/cursorModels.ts:294. That renews the mtime without obtaining a fresh ACP catalog, so even a cache older than 24 hours becomes trusted again and can reject a valid model added since the original snapshot.
Suggested fix:async function applyInMemoryCache( response: ListCursorModelsResponse, persist = true ): Promise<ListCursorModelsResponse> { const enriched = await enrichCursorModelsWithCliSkus(response) if ((enriched.availableModels?.length ?? 0) > 0) { cache.expiresAt = Date.now() + CACHE_TTL_MS cache.response = enriched if (persist) writeSharedCursorModelsCache(enriched) } return enriched } // Use persist=false in both branches that loaded response from the shared file. if (shared) return applyInMemoryCache(shared, false)
Summary
Review mode: initial
- One Major issue: startup prewarm can revalidate an arbitrarily stale Cursor catalog, causing the new guard to block valid session spawns.
- Residual testing gap: no test loads/prewarms an expired shared cache before calling
checkSpawnModel.
Testing
- Not run (automation/security constraint). GitHub integration and fixture checks passed; the main test job was still in progress when reviewed.
HAPI Bot
| // for weeks, and a catalog that predates a Cursor upgrade would reject an id | ||
| // that is now valid. | ||
| const sharedAgeMs = getSharedCursorModelsCacheAgeMs(); | ||
| if (sharedAgeMs !== null && sharedAgeMs <= CACHED_CATALOG_MAX_AGE_MS) { |
There was a problem hiding this comment.
[MAJOR] A cache read can renew stale catalog freshness
This age check is based on file mtime, but runner startup prewarm calls listCursorModels(); its shared-cache branches pass the old response through applyInMemoryCache(), which writes it back and updates that mtime. No fresh ACP catalog was obtained, so an arbitrarily old snapshot becomes trusted for another 24 hours and can reject newly available models.
Suggested fix:
async function applyInMemoryCache(
response: ListCursorModelsResponse,
persist = true
): Promise<ListCursorModelsResponse> {
const enriched = await enrichCursorModelsWithCliSkus(response)
if ((enriched.availableModels?.length ?? 0) > 0) {
cache.expiresAt = Date.now() + CACHE_TTL_MS
cache.response = enriched
if (persist) writeSharedCursorModelsCache(enriched)
}
return enriched
}
// In both shared-cache read branches:
if (shared) return applyInMemoryCache(shared, false)Please also add a regression test that ages the shared file, runs the prewarm/list path, then verifies an unknown model remains allowed.
heavygee
added a commit
to heavygee/hapi
that referenced
this pull request
Sep 2, 2026
…a turn The final check was messages>=1, but the wrapper's own remit ping is that message. A peer whose agent died at startup — a rejected --model spawns a child that never survives the agent handshake — archived with our ping as its only turn and the wrapper still printed OK. That is exactly the empty shell this wrapper exists to catch, one turn later. `active` alone would not have fixed it: the hub marks a session active when the CLI socket connects, and sessionFactory connects that socket before launching the agent, so every spawn reads active the moment we have its id. What separates a live peer from a corpse is what happens next — a dead child sends session-end or stops heartbeating, and the hub drops it inactive within ~30s. So: poll for an agent turn (a message beyond our ping, or thinking) and leave early on it; otherwise wait that window out and require the session to be still active at the end. Undelivered remit still exits 4; delivered-onto-a-corpse now exits 5 with a diagnosis pointing at the machine's model catalog rather than a CLI --help example. The OK line carries which proof it got. A non-numeric HAPI_SPAWN_PEER_VERIFY_TIMEOUT_S is now a usage error up front instead of a set -e crash after the remit has already gone out. Adds hapi-spawn-peer.test.sh, which drives the wrapper against a stub hub modelled on the real one (active from t=0 in every scenario) across live, dead, quiet, silent and bad-timeout. Red-green verified: on the pre-fix wrapper the dead-agent case exits 0. Context: upstream tiann#1752, tiann#1753 via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1752
Problem
Spawning with a model the agent does not have —
--agent cursor --model gpt-5— booted a child that died at the agent handshake. The hub row was already created by then, so the result was an archived session with zero turns and no explanation anywhere: no error on the spawn call, nothing in the session, nothing in the UI.gpt-5is not incursor-agent --list-models(the catalog hasgpt-5.1…gpt-5.6-sol), but nothing on the spawn path checked.SpawnSessionRequestSchema.modelis an unconstrainedz.string().optional()and the runner forwards it straight to--model.Fix
A runner preflight beside the existing agent-availability check: when the machine already has a cached model catalog for the resolved flavor, an id that is not in it fails the spawn with
code: 'model_unavailable'and a message naming the accepted ids. Nothing is spawned, so no zombie row is created.Near-misses are listed first (a rejected
gpt-5is most usefully answered with thegpt-5.xids), and Cursor entries collapse to the base slug so the list is not spent ongpt-5-mini[fast=false]-style variants.Deliberately conservative
The preflight rejects only what it can prove wrong:
agent --list-models, which can take 30s and contends with the ACP spawn lease.[]and is never rejected. Adding a flavor is one line ingetCachedModelCatalog.grok-4.5→cursor-grok-4.5) resolve throughresolveCursorLegacyModelBasethe same waycursorStaleModelRemapdoes, comparison is case-insensitive, andauto/default/default[]are always allowed.Tests
shared/src/spawnModelCatalog.test.ts— 11 cases over the matching rules: rejection message and its ordering, exact/base/sku/legacy-alias/case-insensitive acceptance, wildcards, unknown catalog, and that Cursor sku-suffix stripping is not applied to other flavors.cli/src/runner/spawnModelPreflight.test.ts— 7 cases over the cached-catalog reader: the#1752repro, ACP snapshot and on-disk sources, the 24h freshness bound, flavors with no catalog, and no model at all.Red-green verified: stubbing
validateSpawnModelAgainstCatalogto always returnokfails 4 shared + 2 CLI tests.bun typecheckclean.test:cli2473 pass,test:shared309 pass,test:web2859 pass,test:relay80 pass.test:hubhas 2 failures (TitleSuggestionService,resolveFcmConfig) that reproduce identically with this change reverted and pass when those files run in isolation — pre-existing cross-file pollution, untouched here.Also verified against this machine's real
~/.hapi/cache/cursor-models.json:gpt-5rejected;gpt-5.5,GPT-5.5,grok-4.5,composer-2.5,default[]and no-model all allowed;claude --model gpt-5unaffected.Note
Agent CLI
--helpexamples can drift from--list-modelsand from the hub catalog — this is what sentgpt-5down the spawn path in the first place. This change makes that drift fail loudly at spawn instead of silently at handshake.🤖 Generated with Claude Code