Skip to content

fix(runner): reject a spawn model missing from the machine's catalog - #1753

Open
heavygee wants to merge 1 commit into
tiann:mainfrom
heavygee:fix/spawn-model-validate-1752
Open

fix(runner): reject a spawn model missing from the machine's catalog#1753
heavygee wants to merge 1 commit into
tiann:mainfrom
heavygee:fix/spawn-model-validate-1752

Conversation

@heavygee

@heavygee heavygee commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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-5 is not in cursor-agent --list-models (the catalog has gpt-5.1gpt-5.6-sol), but nothing on the spawn path checked.

SpawnSessionRequestSchema.model is an unconstrained z.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.

Model 'gpt-5' is not in the cursor model catalog on this machine.
Accepted: gpt-5-mini, gpt-5.1, gpt-5.2, gpt-5.3-codex, gpt-5.4, gpt-5.4-mini,
gpt-5.4-nano, gpt-5.5, gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, claude-fable-5, … (36 total)

Near-misses are listed first (a rejected gpt-5 is most usefully answered with the gpt-5.x ids), and Cursor entries collapse to the base slug so the list is not spent on gpt-5-mini[fast=false]-style variants.

Deliberately conservative

The preflight rejects only what it can prove wrong:

  • Never probes. It reads the ACP snapshot, the in-process cache and the on-disk shared cache — nothing else. A spawn must not block on agent --list-models, which can take 30s and contends with the ACP spawn lease.
  • Empty catalog means "unknown", not "no models". Cursor is the only flavor whose catalog is readable without a probe today; every other flavor reports [] and is never rejected. Adding a flavor is one line in getCachedModelCatalog.
  • Stale catalogs go dormant. A cached catalog older than 24h is dropped rather than trusted: a runner can stay up for weeks, and a catalog predating a Cursor upgrade would otherwise reject an id that is now valid.
  • Matching is as permissive as the spawn path. ACP wire ids and CLI skus collapse to their base slug, renamed bases (grok-4.5cursor-grok-4.5) resolve through resolveCursorLegacyModelBase the same way cursorStaleModelRemap does, comparison is case-insensitive, and auto / 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 #1752 repro, ACP snapshot and on-disk sources, the 24h freshness bound, flavors with no catalog, and no model at all.

Red-green verified: stubbing validateSpawnModelAgainstCatalog to always return ok fails 4 shared + 2 CLI tests.

bun typecheck clean. test:cli 2473 pass, test:shared 309 pass, test:web 2859 pass, test:relay 80 pass. test:hub has 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-5 rejected; gpt-5.5, GPT-5.5, grok-4.5, composer-2.5, default[] and no-model all allowed; claude --model gpt-5 unaffected.

Note

Agent CLI --help examples can drift from --list-models and from the hub catalog — this is what sent gpt-5 down 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

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>
@heavygee heavygee added the low-impact Focused / low-blast-radius change; estate lane B promote candidate (PR merge policy) label Sep 2, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls scheduleCursorModelsPrewarm() (cli/src/runner/run.ts:1202). When a shared cache exists, listCursorModels() loads it at cli/src/modules/common/cursorModels.ts:335 and applyInMemoryCache() writes the same catalog back at cli/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) {

@github-actions github-actions Bot Sep 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@heavygee heavygee added bug Something isn't working area:cli CLI, runner, agent wrappers labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli CLI, runner, agent wrappers bug Something isn't working low-impact Focused / low-blast-radius change; estate lane B promote candidate (PR merge policy)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(cli): bad --model on spawn (e.g. cursor gpt-5) archives peer silently; wrapper OK is remit-only

1 participant