coder(providers): v2.3 provider-lifecycle phase 2 — snapshot lifecycle

provider-snapshot no longer returns null for uninstalled/disabled providers: it emits one entry per registered provider with a lifecycle status (loading|ready|unavailable|error), an enabled flag, and a two-tier probe. Tier-1 is a fast which-style check (command-availability.ts, execFile/no-shell); tier-2 (cold ACP probe) is skipped unless forced, last_probed_at is older than PROVIDER_PROBE_TTL_MS (24h), or DB models are empty — the snapshot-latency win. Cache miss returns status:'loading' synchronously while the build settles via the existing inflight promise. ProviderSnapshotStatus/Entry regain loading/unavailable + gain enabled/description?/fetchedAt? in both coder and web copies, guarded by a runtime parity test (provider-types-parity.test.ts; compile-time cross-project check was blocked by TS6307). Also tracks the data/coder-providers.json seed via a .gitignore exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. 13 snapshot tests (+6) + 6 parity tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 11:47:48 +00:00
parent 3730dc9341
commit 35a0aba211
10 changed files with 404 additions and 78 deletions

1
.gitignore vendored
View File

@@ -16,4 +16,5 @@ data/*
!data/AGENTS.md !data/AGENTS.md
!data/skills/ !data/skills/
!data/mcp.json !data/mcp.json
!data/coder-providers.json
codecontext/fork.tar.gz codecontext/fork.tar.gz

View File

@@ -2,6 +2,10 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29 ## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`. Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.

View File

@@ -26,6 +26,10 @@ const ConfigSchema = z.object({
// v2.3: config-backed provider overrides/custom-ACP entries merged over the // v2.3: config-backed provider overrides/custom-ACP entries merged over the
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts). // hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'), CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
// probed more recently than this. 24h default — stale model lists self-heal
// on the next snapshot; an explicit /refresh always re-probes.
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
// v2.0.5: cheaper model for titles, summaries, labeling. // v2.0.5: cheaper model for titles, summaries, labeling.
FAST_MODEL: z.string().optional(), FAST_MODEL: z.string().optional(),
// SSH access to the host for external agent dispatch (Phase 5) // SSH access to the host for external agent dispatch (Phase 5)

View File

@@ -1,10 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { import {
mergeModels, mergeModels,
prefixLlamaSwapModels, prefixLlamaSwapModels,
clearProviderSnapshotCache, clearProviderSnapshotCache,
getProviderSnapshot, getProviderSnapshot,
} from '../provider-snapshot.js'; } from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({ vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(), probeAcpProvider: vi.fn(),
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider); const mockProbe = vi.mocked(probeAcpProvider);
/** Write a temp coder-providers.json and point the resolved registry at it. */
function loadConfigFixture(providers: Record<string, unknown>): void {
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
loadProviderConfig(path);
}
function mockSql(agents: Array<{ function mockSql(agents: Array<{
name: string; name: string;
install_path: string | null; install_path: string | null;
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
models: Array<{ id: string; label: string }> | null; models: Array<{ id: string; label: string }> | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at?: string | null;
}>) { }>) {
return vi.fn((strings: TemplateStringsArray) => { return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join(''); const query = strings.join('');
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
const config = { const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test', LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as import('../config.js').Config; } as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => { describe('prefixLlamaSwapModels', () => {
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
describe('getProviderSnapshot', () => { describe('getProviderSnapshot', () => {
beforeEach(() => { beforeEach(() => {
clearProviderSnapshotCache(); clearProviderSnapshotCache();
// Reset the resolved registry to built-ins-only (missing path → {} config).
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -165,4 +180,147 @@ describe('getProviderSnapshot', () => {
expect(claude?.modes.length).toBeGreaterThan(0); expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true); expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
}); });
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
loadConfigFixture({ goose: { enabled: false } });
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'g1', label: 'G1' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('unavailable');
expect(goose?.enabled).toBe(false);
expect(goose?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([]); // nothing probed/installed
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode?.status).toBe('unavailable');
expect(opencode?.enabled).toBe(true);
expect(opencode?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
loadConfigFixture({});
// If this were wrongly called, cached-goose would be replaced and the
// not.toHaveBeenCalled assertion would fail.
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh
},
]);
// force=false → cache-miss returns loading; second call joins the build / cache.
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('ready');
expect(goose?.installed).toBe(true);
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
expect(mockProbe).not.toHaveBeenCalled();
});
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'fresh-probe', label: 'Fresh' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh, but force overrides
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
expect(mockProbe).toHaveBeenCalled();
});
it('native boocode → ready, enabled, installed', async () => {
loadConfigFixture({});
const sql = mockSql([]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const boocode = entries.find((e) => e.name === 'boocode');
expect(boocode?.status).toBe('ready');
expect(boocode?.enabled).toBe(true);
expect(boocode?.installed).toBe(true);
});
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'm1', label: 'M1' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: null,
label: 'Goose',
transport: 'acp',
last_probed_at: null,
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
const probeCallsAfterFirst = mockProbe.mock.calls.length;
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
const probeCallsAfterSecond = mockProbe.mock.calls.length;
// Success criterion: second snapshot is served from cache with no ACP spawns.
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
});
}); });

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Parity guard between the two copies of the provider snapshot types:
* apps/coder/src/services/provider-types.ts (backend source of truth)
* apps/web/src/api/types.ts (web wire copy)
*
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
* assignability check was attempted first (a web-side file importing coder's
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
* project and rejects out-of-include files with TS6307 — so cross-project type
* import is structurally blocked. This runtime guard FAILS on any field
* add/remove/rename/loosen in either copy, including the nested model/mode/
* command types that ProviderSnapshotEntry references. Single-source-of-truth
* (shared workspace package) is deferred as a Tier-2 follow-up.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
function extractBlock(src: string, name: string): string {
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
const block = iface?.[0] ?? alias?.[0];
if (!block) throw new Error(`type block '${name}' not found`);
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
// trim each line. Field add/remove/rename/loosen still changes a field line.
return block
.split('\n')
.map((l) => l.trim())
.filter(
(l) =>
l.length > 0 &&
!l.startsWith('//') &&
!l.startsWith('/*') &&
!l.startsWith('*'),
)
.join('\n');
}
describe('provider snapshot type parity (coder ↔ web)', () => {
// Includes the nested types ProviderSnapshotEntry references, so structural
// drift anywhere in the snapshot surface is caught.
const names = [
'ProviderSnapshotStatus',
'ProviderSnapshotEntry',
'ProviderModel',
'ProviderMode',
'ThinkingOption',
'AgentCommand',
];
for (const name of names) {
it(`${name} is identical in both copies`, () => {
expect(
extractBlock(webSrc, name),
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
).toBe(extractBlock(coderSrc, name));
});
}
});

View File

@@ -0,0 +1,22 @@
/**
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
*
* Uses execFile (NO shell) because the binary name can come from the provider
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
* `install_path`); this returns a boolean. Kept separate rather than over-
* refactored into one helper — different return contracts, two short call sites.
*/
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
export async function isCommandAvailable(binary: string): Promise<boolean> {
try {
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}

View File

@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import { import {
getManifestDefaultModeId, getManifestDefaultModeId,
getManifestModes, getManifestModes,
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js'; import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
interface AgentRow { interface AgentRow {
name: string; name: string;
@@ -23,6 +24,7 @@ interface AgentRow {
models: ProviderModel[] | null; models: ProviderModel[] | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at: string | Date | null;
} }
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> { async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
@@ -68,113 +70,160 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
} }
async function buildProviderEntry( async function buildProviderEntry(
provider: ProviderDef, resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined, agentRow: AgentRow | undefined,
llamaModels: ProviderModel[], llamaModels: ProviderModel[],
cwd: string, cwd: string,
): Promise<ProviderSnapshotEntry | null> { ttlMs: number,
const isNative = provider.name === 'boocode'; force: boolean,
const installed = isNative || !!agentRow; ): Promise<ProviderSnapshotEntry> {
if (!installed) return null; const name = resolved.id;
const isNative = resolved.transport === 'native';
const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
let transport = provider.transport; // ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) { let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty'; transport = 'pty';
} }
const fallbackModes = getManifestModes(provider.name); // 1. Disabled → unavailable, no probe.
const defaultModeId = getManifestDefaultModeId(provider.name); if (!resolved.enabled) {
if (isNative) {
return { return {
name: provider.name, name, label, ...descr, transport, status: 'unavailable',
label: provider.label, enabled: false, installed: false, models: [], modes: fallbackModes,
transport, defaultModeId, commands: manifestCommands,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
}; };
} }
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: llamaModels, modes: [],
defaultModeId: null, commands: manifestCommands,
};
}
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
const fast =
agentRow?.install_path != null ||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
if (!fast) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: true, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// Baseline model precedence (used by claude + non-probe fallbacks).
let models: ProviderModel[] = []; let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) { if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels; models = llamaModels;
} else if (agentRow?.models?.length) { } else if (agentRow?.models?.length) {
models = agentRow.models; models = agentRow.models;
} else if (provider.staticModels) { } else if (resolved.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label })); models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
} }
if (provider.name === 'claude') { // claude: static models + thinking options, no ACP probe (unchanged from v2.2).
models = attachClaudeThinking(models); if (name === 'claude') {
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId,
transport, commands: manifestCommands,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) { const canProbeAcp =
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd); transport === 'acp' &&
if (probe.models.length > 0) { ((agentRow?.install_path != null && agentRow.supports_acp) ||
models = probe.models; (resolved.isCustomAcp && resolved.launchCommand != null));
} else if (provider.modelSource === 'llama-swap') {
models = llamaModels; if (canProbeAcp) {
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
const lastProbedMs =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
const runTier2 = force || stale || dbEmpty;
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
} }
if (provider.name === 'qwen') { const probeTarget =
const settingsModels = await readQwenSettingsModels(); resolved.isCustomAcp && resolved.launchCommand
models = mergeModels(models, settingsModels); ? resolved.launchCommand[0]
} : agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') { let probeModels = probe.models.length > 0 ? probe.models : models;
const nativeModels = probe.models.length > 0 ? probe.models : models; if (name === 'qwen') {
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); probeModels = mergeModels(probeModels, await readQwenSettingsModels());
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
} }
return { return {
name: provider.name, name, label, transport,
label: agentRow.label ?? provider.label,
transport,
status: probe.ok ? 'ready' : 'error', status: probe.ok ? 'ready' : 'error',
installed: true, enabled: true, installed: true,
models, models: probeModels,
modes: probe.modes.length > 0 ? probe.modes : fallbackModes, modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId, defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands), commands: mergeCommands(manifestCommands, probe.commands),
error: probe.error, ...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
}; };
} }
// PTY-only providers (qwen fallback when ACP unavailable) // PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (provider.name === 'qwen') { if (name === 'qwen' && models.length === 0) {
if (models.length === 0) { models = await readQwenSettingsModels();
models = await readQwenSettingsModels();
}
} }
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models, modes: fallbackModes, defaultModeId, commands: manifestCommands,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */
function loadingEntries(): ProviderSnapshotEntry[] {
return [...getResolvedRegistry().values()].map((r) => ({
name: r.id,
label: r.configLabel ?? r.label,
...(r.configDescription ? { description: r.configDescription } : {}),
transport: r.transport,
status: r.enabled ? ('loading' as const) : ('unavailable' as const),
enabled: r.enabled,
installed: false,
models: [],
modes: getManifestModes(r.id),
defaultModeId: getManifestDefaultModeId(r.id),
commands: getManifestCommands(r.id),
}));
}
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>(); const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>(); const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
const CACHE_TTL_MS = 5 * 60_000; const CACHE_TTL_MS = 5 * 60_000;
@@ -200,16 +249,16 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => { const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config); const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>` const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
`; `;
const agentMap = new Map(agents.map((a) => [a.name, a])); const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const built = await Promise.all( const entries = await Promise.all(
PROVIDERS.map((provider) => [...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd), buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
), ),
); );
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
snapshotCache.set(cacheKey, { at: Date.now(), entries }); snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries; return entries;
@@ -219,7 +268,15 @@ export async function getProviderSnapshot(
snapshotInflight.delete(cacheKey); snapshotInflight.delete(cacheKey);
}); });
snapshotInflight.set(cacheKey, promise); snapshotInflight.set(cacheKey, promise);
return promise;
// force → await the full build (cold probes included). Non-force cache miss →
// return loading entries synchronously; the build settles in the background
// and the next call returns it via cache / inflight (§4.4; client polls).
if (force) return promise;
promise.catch(() => {
/* settled errors surface on the next call that awaits inflight/rebuilds */
});
return loadingEntries();
} }
export function clearProviderSnapshotCache(): void { export function clearProviderSnapshotCache(): void {

View File

@@ -23,24 +23,31 @@ export interface ProviderModel {
defaultThinkingOptionId?: string; defaultThinkingOptionId?: string;
} }
export type ProviderSnapshotStatus = 'ready' | 'error'; // v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface AgentCommand { export interface AgentCommand {
name: string; name: string;
description?: string; description?: string;
} }
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
export interface ProviderSnapshotEntry { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

@@ -232,19 +232,25 @@ export interface ThinkingOption {
isDefault?: boolean; isDefault?: boolean;
} }
export type ProviderSnapshotStatus = 'ready' | 'error'; // v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
export interface ProviderSnapshotEntry { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

@@ -0,0 +1,3 @@
{
"providers": {}
}