-
Notifications
You must be signed in to change notification settings - Fork 0
fix(runner): reject a spawn model missing from the machine's catalog #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9dc0d41
d6cd148
410be98
d5fa896
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { killProcessByChildProcess } from '@/utils/process'; | |
| import { getCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; | ||
| import { getErrorMessage } from './rpcResponses'; | ||
| import { | ||
| getSharedCursorModelsCacheAgeMs, | ||
| readSharedCursorModelsCache, | ||
| writeSharedCursorModelsCache, | ||
| _resetSharedCursorModelsCacheForTests | ||
|
|
@@ -162,6 +163,8 @@ interface CacheEntry { | |
| } | ||
|
|
||
| const CACHE_TTL_MS = 60_000; | ||
| /** Beyond this, a cached catalog is too old to reject a model id against. */ | ||
| const CACHED_CATALOG_MAX_AGE_MS = 24 * 60 * 60 * 1000; | ||
| const PROBE_TIMEOUT_MS = 30_000; | ||
| const cache: CacheEntry = { | ||
| expiresAt: 0, | ||
|
|
@@ -283,12 +286,23 @@ async function runCursorModelProbe(): Promise<ListCursorModelsResponse> { | |
| }); | ||
| } | ||
|
|
||
| async function applyInMemoryCache(response: ListCursorModelsResponse): Promise<ListCursorModelsResponse> { | ||
| /** | ||
| * `persist` must stay false whenever `response` was itself read back off the | ||
| * shared cache. The file's mtime is what dates the catalog for the spawn | ||
| * preflight (getCachedCursorModelIds), so writing an old snapshot back would | ||
| * renew its freshness without anyone having asked Cursor for a new one — a | ||
| * catalog from before a Cursor upgrade would then keep rejecting models that | ||
| * are now valid. Only a live source (ACP snapshot or probe) may stamp the file. | ||
| */ | ||
| 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; | ||
| writeSharedCursorModelsCache(enriched); | ||
| if (persist) writeSharedCursorModelsCache(enriched); | ||
| } | ||
| return enriched; | ||
| } | ||
|
|
@@ -302,7 +316,7 @@ async function listCursorModelsWhileAcpActive(): Promise<ListCursorModelsRespons | |
| // Session child writes the on-disk cache; prefer it over this process's in-memory entry. | ||
| const shared = readSharedCursorModelsCache(); | ||
| if (shared) { | ||
| return applyInMemoryCache(shared); | ||
| return applyInMemoryCache(shared, false); | ||
| } | ||
| if (cache.expiresAt > Date.now() && (cache.response.availableModels?.length ?? 0) > 0) { | ||
| const shared = readSharedCursorModelsCache(); | ||
|
|
@@ -331,7 +345,7 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> { | |
|
|
||
| const shared = readSharedCursorModelsCache(); | ||
| if (shared) { | ||
| return applyInMemoryCache(shared); | ||
| return applyInMemoryCache(shared, false); | ||
| } | ||
|
|
||
| if (inflight) { | ||
|
|
@@ -376,6 +390,40 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> { | |
| return inflight; | ||
| } | ||
|
|
||
| /** | ||
| * Catalog ids from whatever is already cached (ACP snapshot, in-process cache, | ||
| * on-disk shared cache). Never probes: the spawn preflight must not block on | ||
| * `agent --list-models`, which can take 30s and contends with the ACP spawn | ||
| * lease. Returns [] when nothing usable is cached, which callers must read as | ||
| * "unknown catalog", not "no models". | ||
| */ | ||
| export function getCachedCursorModelIds(): string[] { | ||
| const sources: (readonly CursorModelSummary[] | undefined)[] = []; | ||
| // Live ACP session snapshot — current by construction. | ||
| sources.push(getCursorAcpModelsSnapshot()?.availableModels); | ||
|
|
||
| // The in-memory entry and the file are written together by | ||
| // applyInMemoryCache, so the file's mtime dates both. Past the age bound | ||
| // both are dropped rather than trusted past their TTL: a runner can stay up | ||
| // 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) { | ||
|
Comment on lines
+410
to
+411
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the runner startup path, Useful? React with 👍 / 👎. |
||
| sources.push(cache.response.availableModels, cache.response.cliModelSkus); | ||
| const shared = readSharedCursorModelsCache(); | ||
| sources.push(shared?.availableModels, shared?.cliModelSkus); | ||
|
Comment on lines
+412
to
+414
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When runner prewarm has populated Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| const ids = new Set<string>(); | ||
| for (const source of sources) { | ||
| for (const entry of source ?? []) { | ||
| const modelId = entry.modelId.trim(); | ||
| if (modelId) ids.add(modelId); | ||
| } | ||
| } | ||
| return [...ids]; | ||
| } | ||
|
|
||
| export function seedCursorModelsCache(response: ListCursorModelsResponse): void { | ||
| if ((response.availableModels?.length ?? 0) > 0) { | ||
| writeSharedCursorModelsCache(response); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { afterAll, afterEach, describe, expect, it } from 'vitest'; | ||
| import { mkdtempSync, rmSync, utimesSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { setCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge'; | ||
| import { _resetCursorModelsCacheForTests, listCursorModels, seedCursorModelsCache } from '@/modules/common/cursorModels'; | ||
| import { writeSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; | ||
| import { checkSpawnModel } from './spawnModelPreflight'; | ||
|
|
||
| // Isolate the on-disk cursor-models cache to this file's own HAPI_HOME so | ||
| // parallel vitest workers don't race on the shared $HAPI_HOME/cache path. | ||
| const previousHapiHome = process.env.HAPI_HOME; | ||
| const testHapiHome = mkdtempSync(join(tmpdir(), 'hapi-spawn-model-preflight-')); | ||
| process.env.HAPI_HOME = testHapiHome; | ||
|
|
||
| function ageSharedCache(ageMs: number): void { | ||
| const when = new Date(Date.now() - ageMs); | ||
| utimesSync(join(testHapiHome, 'cache', 'cursor-models.json'), when, when); | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| setCursorAcpModelsSnapshot(null); | ||
| _resetCursorModelsCacheForTests(); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| if (previousHapiHome === undefined) delete process.env.HAPI_HOME; | ||
| else process.env.HAPI_HOME = previousHapiHome; | ||
| rmSync(testHapiHome, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('checkSpawnModel', () => { | ||
| it('rejects a Cursor model missing from the cached catalog (#1752)', () => { | ||
| setCursorAcpModelsSnapshot({ | ||
| availableModels: [{ modelId: 'composer-2.5[thinking]' }, { modelId: 'gpt-5.1[reasoning=high]' }], | ||
| currentModelId: 'composer-2.5[thinking]' | ||
| }); | ||
|
|
||
| const result = checkSpawnModel('cursor', 'gpt-5'); | ||
|
|
||
| expect(result.ok).toBe(false); | ||
| expect(result.ok === false && result.message).toContain("Model 'gpt-5' is not in the cursor model catalog"); | ||
| expect(result.ok === false && result.message).toContain('Accepted: gpt-5.1, composer-2.5'); | ||
| }); | ||
|
|
||
| it('accepts a model present in the cached catalog', () => { | ||
| setCursorAcpModelsSnapshot({ | ||
| availableModels: [{ modelId: 'composer-2.5[thinking]' }], | ||
| currentModelId: 'composer-2.5[thinking]' | ||
| }); | ||
|
|
||
| expect(checkSpawnModel('cursor', 'composer-2.5[thinking]')).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('reads the on-disk shared catalog when no ACP snapshot is live', () => { | ||
| seedCursorModelsCache({ | ||
| success: true, | ||
| availableModels: [{ modelId: 'composer-2.5[thinking]' }], | ||
| currentModelId: 'composer-2.5[thinking]' | ||
| }); | ||
|
|
||
| expect(checkSpawnModel('cursor', 'composer-2.5')).toEqual({ ok: true }); | ||
| expect(checkSpawnModel('cursor', 'gpt-5').ok).toBe(false); | ||
| }); | ||
|
|
||
| it('ignores a shared catalog older than the freshness bound', () => { | ||
| seedCursorModelsCache({ | ||
| success: true, | ||
| availableModels: [{ modelId: 'composer-2.5[thinking]' }], | ||
| currentModelId: 'composer-2.5[thinking]' | ||
| }); | ||
| // A catalog that predates a Cursor upgrade must not reject an id that is | ||
| // now valid; the preflight goes dormant instead. | ||
| ageSharedCache(2 * 24 * 60 * 60 * 1000); | ||
|
|
||
| expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('does not let a cache read renew the freshness of a stale catalog', async () => { | ||
| // Fresh runner: cold in-process cache, shared file left over from before a | ||
| // Cursor upgrade. Startup prewarm reads that file; it must not stamp it as | ||
| // current, or the preflight would keep rejecting models added since. | ||
| writeSharedCursorModelsCache({ | ||
| success: true, | ||
| availableModels: [{ modelId: 'composer-2.5' }], | ||
| currentModelId: 'composer-2.5' | ||
| }); | ||
| ageSharedCache(2 * 24 * 60 * 60 * 1000); | ||
|
|
||
| await listCursorModels(); | ||
|
|
||
| expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('allows any model when no catalog is cached', () => { | ||
| expect(checkSpawnModel('cursor', 'gpt-5')).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('allows flavors without a cached catalog', () => { | ||
| expect(checkSpawnModel('claude', 'claude-opus-5')).toEqual({ ok: true }); | ||
| expect(checkSpawnModel('codex', 'gpt-5')).toEqual({ ok: true }); | ||
| }); | ||
|
|
||
| it('allows a spawn with no model', () => { | ||
| setCursorAcpModelsSnapshot({ | ||
| availableModels: [{ modelId: 'composer-2.5[thinking]' }], | ||
| currentModelId: 'composer-2.5[thinking]' | ||
| }); | ||
|
|
||
| expect(checkSpawnModel('cursor', undefined)).toEqual({ ok: true }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import type { AgentFlavor, SpawnModelValidation } from '@hapi/protocol'; | ||
| import { validateSpawnModelAgainstCatalog } from '@hapi/protocol'; | ||
| import { getCachedCursorModelIds } from '@/modules/common/cursorModels'; | ||
|
|
||
| /** | ||
| * Cached-only model catalogs for the spawn preflight. Flavors without a cached | ||
| * catalog return [], which `validateSpawnModelAgainstCatalog` treats as | ||
| * "unknown" and never rejects. Add a flavor here only when its ids can be read | ||
| * without spawning a probe. | ||
| */ | ||
| function getCachedModelCatalog(agent: AgentFlavor): string[] { | ||
| if (agent === 'cursor') return getCachedCursorModelIds(); | ||
| return []; | ||
| } | ||
|
|
||
| /** | ||
| * Fail-closed guard against spawning a child that would die at agent handshake | ||
| * because the requested model does not exist (e.g. `--agent cursor --model gpt-5`), | ||
| * leaving an archived session with no turns behind. | ||
| */ | ||
| export function checkSpawnModel(agent: AgentFlavor, model: string | null | undefined): SpawnModelValidation { | ||
| return validateSpawnModelAgainstCatalog(agent, model, getCachedModelCatalog(agent)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the runner starts with a shared catalog older than 24 hours, startup prewarm enters this branch and returns the cached response without reaching the ACP probe below, while
getCachedCursorModelIds()subsequently discards that same response as expired. The follow-up now preserves the original mtime, but the fresh evidence is that validation therefore remains disabled until a successful Cursor session rewrites the cache, so the first unavailable-model spawn after a day of inactivity can again reach the handshake and leave an empty archived session. Detect the expired cache here and refresh it rather than returning it.Useful? React with 👍 / 👎.