Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions cli/src/modules/common/cursorModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { killProcessByChildProcess } from '@/utils/process';
import { getCursorAcpModelsSnapshot } from '@/cursor/utils/cursorAcpModelsBridge';
import { getErrorMessage } from './rpcResponses';
import {
getSharedCursorModelsCacheAgeMs,
readSharedCursorModelsCache,
writeSharedCursorModelsCache,
_resetSharedCursorModelsCacheForTests
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();
Expand Down Expand Up @@ -331,7 +345,7 @@ export async function listCursorModels(): Promise<ListCursorModelsResponse> {

const shared = readSharedCursorModelsCache();
if (shared) {
return applyInMemoryCache(shared);
return applyInMemoryCache(shared, false);
Comment on lines 346 to +348

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh an expired cache during runner prewarm

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 👍 / 👎.

}

if (inflight) {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the catalog's real observation time

In the runner startup path, scheduleCursorModelsPrewarm() calls listCursorModels(), which reads any existing shared cache through applyInMemoryCache() and writes the same catalog back, refreshing its file mtime without obtaining a new ACP catalog. Consequently this age check treats a stale catalog as fresh after every runner restart or model-list request, so a catalog predating a Cursor upgrade can indefinitely reject newly valid models and continue accepting removed ones. Store an observation timestamp that is preserved when reusing cached data, or refresh the ACP catalog before renewing it.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Discard superseded in-memory catalogs

When runner prewarm has populated cache.response and a later Cursor child overwrites the shared file with a changed catalog, these lines union the runner's old IDs with the new on-disk IDs while using the new file's mtime to declare both fresh. A model removed from Cursor therefore remains accepted until the runner restarts, reaches the handshake, and can still produce the empty archived session this preflight targets. Fresh evidence after the prior mtime fix is that a child-process write refreshes the file without replacing the runner process's cache.response; prefer the newest snapshot rather than combining catalog generations.

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);
Expand Down
12 changes: 11 additions & 1 deletion cli/src/modules/common/cursorModelsSharedCache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { CursorModelsResponse } from '@hapi/protocol/apiTypes';
import { resolveHapiHomeDir } from '@/configuration';
Expand Down Expand Up @@ -33,6 +33,16 @@ export function readSharedCursorModelsCache(): CursorModelsResponse | null {
}
}

/** Age of the on-disk catalog in ms, or null when there is no cache file. */
export function getSharedCursorModelsCacheAgeMs(): number | null {
const path = getSharedCachePath();
try {
return Math.max(0, Date.now() - statSync(path).mtimeMs);
} catch {
return null;
}
}

export function writeSharedCursorModelsCache(response: CursorModelsResponse): void {
if (!isUsableModelsResponse(response)) {
return;
Expand Down
2 changes: 1 addition & 1 deletion cli/src/modules/common/rpcTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ export type SpawnSessionResult =
| {
type: 'error'
errorMessage: string
code?: 'agent_unavailable' | 'outside_workspace_roots'
code?: 'agent_unavailable' | 'model_unavailable' | 'outside_workspace_roots'
agent?: AgentFlavor
}
15 changes: 15 additions & 0 deletions cli/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { buildMachineMetadata } from '@/agent/sessionFactory';
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity';
import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm';
import { checkSpawnModel } from './spawnModelPreflight';
import { isLinkedGitWorktree } from '@/utils/isLinkedGitWorktree';
import { agentUnavailableMessage, getAgentAvailability } from '@/agent/agentAvailability';

Expand Down Expand Up @@ -515,6 +516,20 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
agent
};
}
const modelCheck = checkSpawnModel(agent, options.model);
if (!modelCheck.ok) {
logger.debug(`[RUNNER RUN] Model preflight failed: ${modelCheck.message}`);
reportSpawnOutcomeToHub?.({
type: 'error',
details: { message: modelCheck.message }
});
return {
type: 'error',
errorMessage: modelCheck.message,
code: 'model_unavailable',
agent
};
}
if (options.validateDirectory && !(await options.validateDirectory(directory))) {
return {
type: 'error',
Expand Down
112 changes: 112 additions & 0 deletions cli/src/runner/spawnModelPreflight.test.ts
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 });
});
});
23 changes: 23 additions & 0 deletions cli/src/runner/spawnModelPreflight.ts
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));
}
4 changes: 2 additions & 2 deletions hub/src/sync/rpcGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export class RpcGateway {
| {
type: 'error'
message: string
code?: 'agent_unavailable' | 'outside_workspace_roots'
code?: 'agent_unavailable' | 'model_unavailable' | 'outside_workspace_roots'
agent?: AgentFlavor
}
> {
Expand Down Expand Up @@ -228,7 +228,7 @@ export class RpcGateway {
return { type: 'success', sessionId: obj.sessionId }
}
if (obj.type === 'error' && typeof obj.errorMessage === 'string') {
const code = obj.code === 'agent_unavailable' || obj.code === 'outside_workspace_roots'
const code = obj.code === 'agent_unavailable' || obj.code === 'model_unavailable' || obj.code === 'outside_workspace_roots'
? obj.code
: undefined
const unavailableAgent = typeof obj.agent === 'string' ? obj.agent as AgentFlavor : undefined
Expand Down
2 changes: 1 addition & 1 deletion shared/src/apiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export type SpawnResponse =
| {
type: 'error'
message: string
code?: 'agent_unavailable' | 'runner_upgrade_required' | 'outside_workspace_roots'
code?: 'agent_unavailable' | 'model_unavailable' | 'runner_upgrade_required' | 'outside_workspace_roots'
agent?: z.infer<typeof AgentFlavorSchema>
}

Expand Down
1 change: 1 addition & 0 deletions shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './socket'
export * from './sessionSummary'
export * from './sessionCitation'
export * from './sessionExport'
export * from './spawnModelCatalog'
export * from './piThinkingLevel'
export * from './runnerCapabilities'
export * from './agentConfig'
Expand Down
Loading
Loading