Make provider catalog refresh deadlines configurable (#3322)

* fix(providers): make catalog refresh deadlines configurable

Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background.

* fix(opencode): abort catalog server acquisition

A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller.

* test(file-observer): allow native burst recovery
This commit is contained in:
Mohamed Boudra
2026-08-13 15:08:46 +02:00
committed by GitHub
parent d0d60a4052
commit 20bccd31a3
33 changed files with 862 additions and 245 deletions
+15 -1
View File
@@ -2,7 +2,7 @@
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider. Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
All provider configuration lives under `agents.providers` in config.json: Provider definitions live under `agents.providers` in config.json:
```json ```json
{ {
@@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json:
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
Each provider catalog refresh waits up to 60 seconds. If a provider loads many plugins or a large
agent catalog during startup, raise the limit in milliseconds:
```json
{
"agents": {
"catalogRefreshTimeoutMs": 180000
}
}
```
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
--- ---
## Table of Contents ## Table of Contents
+7
View File
@@ -105,6 +105,13 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
multiple catalog requests must not apply this deadline separately to each request. The manager
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
@@ -668,15 +668,19 @@ export type FetchCatalogOptions =
| { | {
scope: "global"; scope: "global";
force: boolean; force: boolean;
timeoutMs?: number;
} }
| { | {
scope: "workspace"; scope: "workspace";
cwd: string; cwd: string;
force: boolean; force: boolean;
timeoutMs?: number;
}; };
export interface ProviderRefreshContext {
readonly signal: AbortSignal;
/** Track an upstream operation so timeout errors identify the work still pending. */
runActivity<T>(name: string, operation: () => Promise<T>): Promise<T>;
}
export interface ProviderCatalog { export interface ProviderCatalog {
models: AgentModelDefinition[]; models: AgentModelDefinition[];
modes: AgentMode[]; modes: AgentMode[];
@@ -686,6 +690,7 @@ export interface ProviderCatalog {
export interface ResolveAgentDefaultModeInput { export interface ResolveAgentDefaultModeInput {
config: AgentSessionConfig; config: AgentSessionConfig;
env?: Record<string, string>; env?: Record<string, string>;
signal?: AbortSignal;
} }
export interface AgentClient { export interface AgentClient {
@@ -707,8 +712,13 @@ export interface AgentClient {
* process, separate upstream calls, static modes, or private helpers; callers * process, separate upstream calls, static modes, or private helpers; callers
* outside the provider do not get separate runtime model/mode probes. * outside the provider do not get separate runtime model/mode probes.
* The registry is responsible for merging configured model overrides. * The registry is responsible for merging configured model overrides.
* ProviderSnapshotManager supplies a shared context. Providers must pass its
* signal downstream and finish resource cleanup before rejecting on abort.
*/ */
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>; fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog>;
/** Apply provider-owned defaults to a model supplied through provider configuration. */ /** Apply provider-owned defaults to a model supplied through provider configuration. */
resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition; resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition;
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>; resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
@@ -727,7 +737,7 @@ export interface AgentClient {
* Check if this provider is available (CLI binary is installed). * Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise. * Returns true if available, false otherwise.
*/ */
isAvailable(): Promise<boolean>; isAvailable(signal?: AbortSignal): Promise<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>; getDiagnostic?(): Promise<{ diagnostic: string }>;
/** /**
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
@@ -0,0 +1,80 @@
import type { ProviderRefreshContext } from "./agent-sdk-types.js";
interface RunProviderRefreshOptions<T> {
label: string;
timeoutMs: number;
operation: (context: ProviderRefreshContext) => Promise<T>;
}
export function runProviderRefreshActivity<T>(
context: ProviderRefreshContext | undefined,
name: string,
operation: () => Promise<T>,
): Promise<T> {
return context ? context.runActivity(name, operation) : operation();
}
export async function raceProviderRefreshAbort<T>(
signal: AbortSignal | undefined,
operation: Promise<T>,
): Promise<T> {
if (!signal) return await operation;
signal.throwIfAborted();
let handleAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
handleAbort = () => reject(signal.reason);
signal.addEventListener("abort", handleAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (handleAbort) signal.removeEventListener("abort", handleAbort);
}
}
export async function runProviderRefreshWithDeadline<T>(
options: RunProviderRefreshOptions<T>,
): Promise<T> {
const controller = new AbortController();
const activityCounts = new Map<string, number>();
let timeoutError: Error | undefined;
const context: ProviderRefreshContext = {
signal: controller.signal,
async runActivity(name, operation) {
controller.signal.throwIfAborted();
activityCounts.set(name, (activityCounts.get(name) ?? 0) + 1);
try {
const result = await operation();
controller.signal.throwIfAborted();
return result;
} finally {
const remaining = (activityCounts.get(name) ?? 1) - 1;
if (remaining === 0) {
activityCounts.delete(name);
} else {
activityCounts.set(name, remaining);
}
}
},
};
const timer = setTimeout(() => {
const pending = Array.from(activityCounts.keys());
const suffix = pending.length > 0 ? `; pending: ${pending.join(", ")}` : "";
timeoutError = new Error(
`Timed out refreshing ${options.label} after ${options.timeoutMs}ms${suffix}`,
);
controller.abort(timeoutError);
}, options.timeoutMs);
try {
const result = await options.operation(context);
if (timeoutError) throw timeoutError;
return result;
} catch (error) {
throw timeoutError ?? error;
} finally {
clearTimeout(timer);
}
}
@@ -13,6 +13,7 @@ import type {
AgentSession, AgentSession,
AgentStreamEvent, AgentStreamEvent,
FetchCatalogOptions, FetchCatalogOptions,
ProviderRefreshContext,
ProviderCatalog, ProviderCatalog,
ResolveAgentCreateConfigInput, ResolveAgentCreateConfigInput,
ResolveAgentCreateConfigResult, ResolveAgentCreateConfigResult,
@@ -24,6 +25,7 @@ import {
resolveDefaultAgentCreateConfig, resolveDefaultAgentCreateConfig,
} from "./create-agent-mode.js"; } from "./create-agent-mode.js";
import { normalizeAgentModelDefinition } from "./agent-sdk-types.js"; import { normalizeAgentModelDefinition } from "./agent-sdk-types.js";
import { runProviderRefreshActivity } from "./provider-refresh-deadline.js";
import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js"; import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
import type { import type {
@@ -92,7 +94,11 @@ export interface ProviderDefinition extends AgentProviderDefinition {
* Single catalog discovery call used by ProviderSnapshotManager. Should spawn * Single catalog discovery call used by ProviderSnapshotManager. Should spawn
* at most one provider runtime process and return both models and modes. * at most one provider runtime process and return both models and modes.
*/ */
fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise<ProviderCatalog>; fetchCatalog: (
options: FetchCatalogOptions,
client?: AgentClient,
context?: ProviderRefreshContext,
) => Promise<ProviderCatalog>;
} }
export interface BuildProviderRegistryOptions { export interface BuildProviderRegistryOptions {
@@ -507,8 +513,8 @@ function wrapClientProvider(
options, options,
), ),
), ),
fetchCatalog: async (options) => { fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog(options); const catalog = await inner.fetchCatalog(options, context);
return { return {
...catalog, ...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, { models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -518,10 +524,11 @@ function wrapClientProvider(
}; };
}, },
resolveDefaultModeId: inner.resolveDefaultModeId resolveDefaultModeId: inner.resolveDefaultModeId
? async ({ config, env }: ResolveAgentDefaultModeInput) => ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) =>
await inner.resolveDefaultModeId?.({ await inner.resolveDefaultModeId?.({
config: { ...config, provider: inner.provider }, config: { ...config, provider: inner.provider },
env, env,
signal,
}) })
: undefined, : undefined,
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
@@ -561,7 +568,7 @@ function wrapClientProvider(
}; };
} }
: undefined, : undefined,
isAvailable: () => inner.isAvailable(), isAvailable: (signal) => inner.isAvailable(signal),
getDiagnostic: inner.getDiagnostic?.bind(inner), getDiagnostic: inner.getDiagnostic?.bind(inner),
}; };
} }
@@ -621,7 +628,11 @@ function createRegistryEntry(
resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig, resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig,
isCreateConfigUnattended: isCreateConfigUnattended:
modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended, modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended,
fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => { fetchCatalog: async (
options: FetchCatalogOptions,
client?: AgentClient,
context?: ProviderRefreshContext,
) => {
const catalogClient = client ?? modelClient; const catalogClient = client ?? modelClient;
if (hasReplacementModels) { if (hasReplacementModels) {
// Replacement models skip runtime model discovery, but additionalModels // Replacement models skip runtime model discovery, but additionalModels
@@ -629,23 +640,29 @@ function createRegistryEntry(
// the single catalog API; otherwise use static/empty modes with no runtime. // the single catalog API; otherwise use static/empty modes with no runtime.
const models = mergeModelAdditions(provider, replacementModels, additionalModels); const models = mergeModelAdditions(provider, replacementModels, additionalModels);
if (hasStaticModes) { if (hasStaticModes) {
const defaultModeId = await catalogClient.resolveDefaultModeId?.({ const defaultModeId = await runProviderRefreshActivity(
context,
"default-mode",
async () =>
await catalogClient.resolveDefaultModeId?.({
config: { config: {
provider, provider,
cwd: options.scope === "workspace" ? options.cwd : process.cwd(), cwd: options.scope === "workspace" ? options.cwd : process.cwd(),
}, },
}); signal: context?.signal,
}),
);
return { return {
models, models,
modes: decorateModes(resolved.definition.modes), modes: decorateModes(resolved.definition.modes),
defaultModeId, defaultModeId,
}; };
} }
const catalog = await catalogClient.fetchCatalog(options); const catalog = await catalogClient.fetchCatalog(options, context);
return { ...catalog, models, modes: decorateModes(catalog.modes) }; return { ...catalog, models, modes: decorateModes(catalog.modes) };
} }
const catalog = await catalogClient.fetchCatalog(options); const catalog = await catalogClient.fetchCatalog(options, context);
return { return {
...catalog, ...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, { models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -9,6 +9,7 @@ import type {
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
FetchCatalogOptions, FetchCatalogOptions,
ProviderRefreshContext,
ResolveAgentCreateConfigInput, ResolveAgentCreateConfigInput,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
@@ -68,6 +69,41 @@ async function withEnv(key: string, value: string, run: () => Promise<void>): Pr
} }
} }
function waitUntilAborted(signal?: AbortSignal): Promise<boolean> {
return new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
function waitForDelay(delayMs: number): Promise<void> {
return new Promise((finish) => setTimeout(finish, delayMs));
}
function waitForAbortWithCleanup(
signal: AbortSignal,
cleanupState: { cleanedUp: boolean },
): Promise<void> {
return new Promise((_resolve, reject) => {
const handleAbort = () => {
cleanupState.cleanedUp = true;
reject(signal.reason);
};
signal.addEventListener("abort", handleAbort, { once: true });
});
}
async function runTestCatalogActivities(
context: ProviderRefreshContext,
cleanupState: { cleanedUp: boolean },
): Promise<void> {
const waitForAgents = () => waitForDelay(50);
const waitForProviders = () => waitForAbortWithCleanup(context.signal, cleanupState);
await Promise.all([
context.runActivity("app.agents", waitForAgents),
context.runActivity("provider.list", waitForProviders),
]);
}
describe("ProviderSnapshotManager public surface", () => { describe("ProviderSnapshotManager public surface", () => {
test("validates complete Hub agent configurations through the current provider contract", async () => { test("validates complete Hub agent configurations through the current provider contract", async () => {
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
@@ -380,7 +416,7 @@ describe("ProviderSnapshotManager public surface", () => {
test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => {
// never-resolving isAvailable forces the timeout path // never-resolving isAvailable forces the timeout path
const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); const isAvailable = vi.fn(waitUntilAborted);
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
refreshTimeoutMs: 1, refreshTimeoutMs: 1,
@@ -406,9 +442,158 @@ describe("ProviderSnapshotManager public surface", () => {
} }
}); });
test("one refresh timeout covers availability and catalog discovery", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: () =>
new Promise((settle) => {
setTimeout(() => settle(true), 60);
}),
fetchCatalog: () =>
new Promise((settle) => {
setTimeout(() => settle({ models: [], modes: [] }), 60);
}),
}),
},
});
try {
const entryPromise = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(120);
await expect(entryPromise).resolves.toMatchObject({
provider: "codex",
status: "error",
error: "Timed out refreshing Codex after 100ms",
});
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("timeout names pending catalog activities, aborts them, and waits for cleanup", async () => {
vi.useFakeTimers();
let attempt = 0;
const cleanupState = { cleanedUp: false };
const fetchCatalog = vi.fn(
async (_options: FetchCatalogOptions, context?: ProviderRefreshContext) => {
attempt += 1;
if (attempt > 1) {
expect(cleanupState.cleanedUp).toBe(true);
return { models: [], modes: [] };
}
if (!context) throw new Error("missing refresh context");
await runTestCatalogActivities(context, cleanupState);
return { models: [], modes: [] };
},
);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog,
}),
},
});
try {
const first = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(100);
await expect(first).resolves.toMatchObject({
status: "error",
error: "Timed out refreshing Codex after 100ms; pending: provider.list",
});
expect(cleanupState.cleanedUp).toBe(true);
await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] });
expect(fetchCatalog).toHaveBeenCalledTimes(2);
expect(
manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"),
).toMatchObject({ provider: "codex", status: "ready" });
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("each provider refresh gets its own timeout", async () => {
vi.useFakeTimers();
const neverReturnsCatalog = (_options: FetchCatalogOptions, context?: ProviderRefreshContext) =>
new Promise<never>((_resolve, reject) => {
context?.signal.addEventListener("abort", () => reject(context.signal.reason), {
once: true,
});
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: neverReturnsCatalog,
}),
claude: createExtraClient("claude", {
isAvailable: async () => true,
fetchCatalog: neverReturnsCatalog,
}),
},
});
try {
const codexPromise = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(50);
const claudePromise = manager.getProvider({
cwd: "/tmp/project",
provider: "claude",
wait: true,
});
await vi.advanceTimersByTimeAsync(50);
await expect(codexPromise).resolves.toMatchObject({
provider: "codex",
status: "error",
});
await expect(
manager.getProvider({ cwd: "/tmp/project", provider: "claude", wait: false }),
).resolves.toMatchObject({ provider: "claude", status: "loading" });
await vi.advanceTimersByTimeAsync(50);
await expect(claudePromise).resolves.toMatchObject({
provider: "claude",
status: "error",
});
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); const isAvailable = vi.fn(waitUntilAborted);
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
providerOverrides: { providerOverrides: {
@@ -435,7 +620,7 @@ describe("ProviderSnapshotManager public surface", () => {
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); const isAvailable = vi.fn(waitUntilAborted);
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
logger: createTestLogger(), logger: createTestLogger(),
refreshTimeoutMs: 5, refreshTimeoutMs: 5,
@@ -708,7 +893,10 @@ describe("ProviderSnapshotManager public surface", () => {
extraClients: { extraClients: {
codex: createExtraClient("codex", { codex: createExtraClient("codex", {
isAvailable: async () => true, isAvailable: async () => true,
fetchCatalog: async () => new Promise(() => {}), fetchCatalog: async (_options, context) => {
await context?.runActivity("model/list", () => waitUntilAborted(context.signal));
return { models: [], modes: [] };
},
getDiagnostic: async () => { getDiagnostic: async () => {
diagnosticStarted = true; diagnosticStarted = true;
return { diagnostic: "codex diagnostics available" }; return { diagnostic: "codex diagnostics available" };
@@ -751,9 +939,10 @@ describe("ProviderSnapshotManager public surface", () => {
extraClients: { extraClients: {
codex: createExtraClient("codex", { codex: createExtraClient("codex", {
isAvailable: async () => true, isAvailable: async () => true,
fetchCatalog: async () => { fetchCatalog: async (_options, context) => {
snapshotStarted = true; snapshotStarted = true;
return new Promise(() => {}); await context?.runActivity("model/list", () => waitUntilAborted(context.signal));
return { models: [], modes: [] };
}, },
getDiagnostic: async () => { getDiagnostic: async () => {
diagnosticStarted = true; diagnosticStarted = true;
@@ -16,6 +16,10 @@ import {
type FetchCatalogOptions, type FetchCatalogOptions,
type ProviderSnapshotEntry, type ProviderSnapshotEntry,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import {
raceProviderRefreshAbort,
runProviderRefreshWithDeadline,
} from "./provider-refresh-deadline.js";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js"; import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
@@ -42,6 +46,7 @@ import {
} from "./agent-configuration-validator.js"; } from "./agent-configuration-validator.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000; const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global"; export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
@@ -50,14 +55,19 @@ export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). // `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
// Allow operators to bump the ceiling via env var without rebuilding. // Allow operators to bump the ceiling via env var without rebuilding.
function resolveRefreshTimeoutMs(option: number | undefined): number { function resolveRefreshTimeoutMs(option: number | undefined): number {
if (typeof option === "number" && Number.isFinite(option) && option > 0) { if (
typeof option === "number" &&
Number.isSafeInteger(option) &&
option > 0 &&
option <= MAX_REFRESH_TIMEOUT_MS
) {
return option; return option;
} }
const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR];
if (fromEnv) { if (fromEnv) {
// Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate.
const parsed = Number(fromEnv); const parsed = Number(fromEnv);
if (Number.isFinite(parsed) && parsed > 0) { if (Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_REFRESH_TIMEOUT_MS) {
return parsed; return parsed;
} }
} }
@@ -515,7 +525,7 @@ export class ProviderSnapshotManager {
client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig, client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig,
isCreateConfigUnattended: isCreateConfigUnattended:
client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended, client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended,
fetchCatalog: client.fetchCatalog.bind(client), fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context),
}; };
} }
@@ -834,22 +844,25 @@ export class ProviderSnapshotManager {
} }
const client = this.ensureClient(provider, definition); const client = this.ensureClient(provider, definition);
const available = await withTimeout( const catalog = await runProviderRefreshWithDeadline({
client.isAvailable(), label: definition.label,
this.refreshTimeoutMs, timeoutMs: this.refreshTimeoutMs,
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`, operation: async (context) => {
const available = await context.runActivity("availability", () =>
raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)),
); );
if (!available) { if (!available) {
setEntry({ ...base, status: "unavailable", enabled: true }); return null;
return;
} }
const catalogOptions = createFetchCatalogOptions(catalogScope, force); const catalogOptions = createFetchCatalogOptions(catalogScope, force);
const catalog = await withTimeout( return await definition.fetchCatalog(catalogOptions, client, context);
definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client), },
this.refreshTimeoutMs, });
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`, if (!catalog) {
); setEntry({ ...base, status: "unavailable", enabled: true });
return;
}
setEntry({ setEntry({
...base, ...base,
@@ -85,6 +85,7 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type FetchCatalogOptions, type FetchCatalogOptions,
type ProviderRefreshContext,
type ImportableProviderSession, type ImportableProviderSession,
type ImportProviderSessionContext, type ImportProviderSessionContext,
type ImportProviderSessionInput, type ImportProviderSessionInput,
@@ -96,6 +97,10 @@ import {
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import {
raceProviderRefreshAbort,
runProviderRefreshActivity,
} from "../provider-refresh-deadline.js";
import { import {
isDefaultAgentCreateConfigUnattended, isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig, resolveDefaultAgentCreateConfig,
@@ -267,7 +272,6 @@ export function buildACPClientCapabilities(
// sign-in URL in the browser) when probing an ACP agent for models/modes. // sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it. // NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" }; const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" };
const ACP_CATALOG_TIMEOUT_MS = 60_000;
const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
@@ -934,24 +938,44 @@ export class ACPAgentClient implements AgentClient {
return session; return session;
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const cwd = options.scope === "global" ? homedir() : options.cwd; const cwd = options.scope === "global" ? homedir() : options.cwd;
const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS;
let probe: UninitializedACPProcess | null = null; let probe: UninitializedACPProcess | null = null;
let closePromise: Promise<void> | null = null;
const closeProbe = (): Promise<void> => {
if (!probe) return Promise.resolve();
closePromise ??= this.closeProbe(probe);
return closePromise;
};
const handleAbort = () => void closeProbe().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try { try {
const catalogProbe = (async () => { const initializedProbe = await runProviderRefreshActivity(context, "initialize", () =>
const initializedProbe = await this.spawnProcess(PROBE_ENV, { raceProviderRefreshAbort(
initializeTimeoutMs: timeoutMs, context?.signal,
this.spawnProcess(PROBE_ENV, {
onSpawned: (spawned) => { onSpawned: (spawned) => {
probe = spawned; probe = spawned;
if (context?.signal.aborted) void closeProbe().catch(() => undefined);
}, },
}); }),
),
);
probe = initializedProbe; probe = initializedProbe;
const response = await this.runACPRequest(() => const response = await runProviderRefreshActivity(context, "session/new", () =>
raceProviderRefreshAbort(
context?.signal,
this.runACPRequest(() =>
initializedProbe.connection.newSession({ initializedProbe.connection.newSession({
cwd, cwd,
mcpServers: [], mcpServers: [],
}), }),
),
),
); );
const transformed = this.transformSessionResponse(response); const transformed = this.transformSessionResponse(response);
const derivedModels = deriveModelDefinitionsFromACP( const derivedModels = deriveModelDefinitionsFromACP(
@@ -960,7 +984,10 @@ export class ACPAgentClient implements AgentClient {
transformed.configOptions, transformed.configOptions,
); );
const models = this.catalogModelResolver const models = this.catalogModelResolver
? await this.catalogModelResolver({ ? await runProviderRefreshActivity(context, "catalog.resolve", () =>
raceProviderRefreshAbort(
context?.signal,
this.catalogModelResolver?.({
connection: initializedProbe.connection, connection: initializedProbe.connection,
sessionId: response.sessionId, sessionId: response.sessionId,
models: derivedModels, models: derivedModels,
@@ -972,7 +999,9 @@ export class ACPAgentClient implements AgentClient {
: configOptions, : configOptions,
logger: this.logger, logger: this.logger,
provider: this.provider, provider: this.provider,
}) }) ?? Promise.resolve(derivedModels),
),
)
: derivedModels; : derivedModels;
const modeInfo = deriveModesFromACP( const modeInfo = deriveModesFromACP(
this.defaultModes, this.defaultModes,
@@ -983,17 +1012,9 @@ export class ACPAgentClient implements AgentClient {
models: this.modelTransformer ? this.modelTransformer(models) : models, models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes, modes: modeInfo.modes,
}; };
})();
return await withTimeout(
catalogProbe,
timeoutMs,
`ACP catalog probe timed out after ${timeoutMs}ms`,
);
} finally { } finally {
if (probe) { context?.signal.removeEventListener("abort", handleAbort);
await this.closeProbe(probe); await closeProbe();
}
} }
} }
@@ -121,9 +121,11 @@ import {
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
type McpServerConfig, type McpServerConfig,
type ProviderCatalog, type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput, type ResolveAgentDefaultModeInput,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js"; import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
createProviderEnv, createProviderEnv,
@@ -392,7 +394,7 @@ interface ClaudeAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory; queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>; resolveBinary?: () => Promise<string>;
resolveVersion?: () => Promise<string>; resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string; configDir?: string;
} }
@@ -1476,7 +1478,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory?: ClaudeQueryFactory; private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>; private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: () => Promise<string>; private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly configDir?: string; private readonly configDir?: string;
constructor(options: ClaudeAgentClientOptions) { constructor(options: ClaudeAgentClientOptions) {
@@ -1486,7 +1488,8 @@ export class ClaudeAgentClient implements AgentClient {
this.queryFactory = options.queryFactory; this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
this.resolveVersion = this.resolveVersion =
options.resolveVersion ?? (() => resolveClaudeCodeVersion(this.runtimeSettings)); options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.configDir = options.configDir; this.configDir = options.configDir;
} }
@@ -1540,18 +1543,21 @@ export class ClaudeAgentClient implements AgentClient {
}); });
} }
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
_options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant. // Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
let claudeCodeVersion: string | undefined; let claudeCodeVersion: string | undefined;
try { try {
claudeCodeVersion = await this.resolveVersion(); claudeCodeVersion = await runProviderRefreshActivity(context, "version", () =>
this.resolveVersion(context?.signal),
);
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
} }
const models = await getClaudeModelsWithSettings( const models = await runProviderRefreshActivity(context, "settings", () =>
this.logger, getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
this.configDir,
claudeCodeVersion,
); );
const modes = detectIneligibleAutoModeTransport( const modes = detectIneligibleAutoModeTransport(
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
@@ -1680,6 +1686,7 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P
export async function resolveClaudeCodeVersion( export async function resolveClaudeCodeVersion(
runtimeSettings?: ProviderRuntimeSettings, runtimeSettings?: ProviderRuntimeSettings,
signal?: AbortSignal,
): Promise<string> { ): Promise<string> {
const launch = await resolveProviderLaunch({ const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command, commandConfig: runtimeSettings?.command,
@@ -1693,6 +1700,7 @@ export async function resolveClaudeCodeVersion(
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], { const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
...createProviderEnvSpec({ runtimeSettings }), ...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000, timeout: 5_000,
signal,
}); });
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
if (!version) { if (!version) {
@@ -33,8 +33,11 @@ import {
type ImportProviderSessionInput, type ImportProviderSessionInput,
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
type ProviderCatalog, type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
@@ -6611,29 +6614,31 @@ export class CodexAppServerAgentClient implements AgentClient {
return this.goalsEnabledPromise; return this.goalsEnabledPromise;
} }
private resolveAutoReviewEnabled(): Promise<boolean> { private resolveAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> {
if (signal) return this.probeAutoReviewEnabled(signal);
if (!this.autoReviewEnabledPromise) { if (!this.autoReviewEnabledPromise) {
this.autoReviewEnabledPromise = (async () => { this.autoReviewEnabledPromise = this.probeAutoReviewEnabled();
}
return this.autoReviewEnabledPromise;
}
private async probeAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> {
try { try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command); signal?.throwIfAborted();
const versionOutput = await resolveBinaryVersion(launchPrefix.command, signal);
signal?.throwIfAborted();
const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION); const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION);
this.logger.trace( this.logger.trace(
{ { provider: CODEX_PROVIDER, versionOutput, enabled },
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.auto_review_resolved", "provider.codex.config.auto_review_resolved",
); );
return enabled; return enabled;
} catch (error) { } catch (error) {
if (signal?.aborted) throw signal.reason;
this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate"); this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate");
return false; return false;
} }
})();
}
return this.autoReviewEnabledPromise;
} }
private async spawnAppServer( private async spawnAppServer(
@@ -6787,10 +6792,15 @@ export class CodexAppServerAgentClient implements AgentClient {
}); });
} }
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
_options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const [models, autoReviewEnabled] = await Promise.all([ const [models, autoReviewEnabled] = await Promise.all([
this.fetchModelsFromAppServer(), this.fetchModelsFromAppServer(context),
this.resolveAutoReviewEnabled(), runProviderRefreshActivity(context, "version", () =>
this.resolveAutoReviewEnabled(context?.signal),
),
]); ]);
return { return {
models, models,
@@ -6801,23 +6811,46 @@ export class CodexAppServerAgentClient implements AgentClient {
}; };
} }
async resolveDefaultModeId(): Promise<string> { async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID; return (await this.resolveAutoReviewEnabled(input.signal))
? "auto-review"
: DEFAULT_CODEX_MODE_ID;
} }
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> { private async fetchModelsFromAppServer(
context?: ProviderRefreshContext,
): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer(); let client: CodexAppServerClient | undefined;
const client = new CodexAppServerClient(child, this.logger); let disposePromise: Promise<void> | undefined;
const dispose = () => {
if (!client) return Promise.resolve();
disposePromise ??= client.dispose();
return disposePromise;
};
const handleAbort = () => void dispose().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try { try {
await client.request("initialize", buildCodexAppServerInitializeParams()); await runProviderRefreshActivity(context, "app-server.start", async () => {
const child = await this.spawnAppServer();
client = new CodexAppServerClient(child, this.logger);
if (context?.signal.aborted) await dispose();
});
if (!client) throw new Error("Codex app-server did not start");
await runProviderRefreshActivity(context, "initialize", () =>
client!.request("initialize", buildCodexAppServerInitializeParams()),
);
client.notify("initialized", {}); client.notify("initialized", {});
const rawResponse = await client.request("model/list", {}); const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
client!.request("model/list", {}),
);
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse); const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : []; const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger); const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
readCodexConfiguredDefaults(client!, this.logger),
);
const configuredDefaultModelId = configuredDefaults.model; const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId; const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
const hasConfiguredDefaultModel = const hasConfiguredDefaultModel =
@@ -6832,7 +6865,8 @@ export class CodexAppServerAgentClient implements AgentClient {
}), }),
); );
} finally { } finally {
await client.dispose(); context?.signal.removeEventListener("abort", handleAbort);
await dispose();
} }
} }
@@ -135,11 +135,15 @@ export function toDiagnosticErrorMessage(error: unknown): string {
return formatNonErrorDiagnostic(error); return formatNonErrorDiagnostic(error);
} }
export async function resolveBinaryVersion(binaryPath: string): Promise<string> { export async function resolveBinaryVersion(
binaryPath: string,
signal?: AbortSignal,
): Promise<string> {
try { try {
const { stdout } = await execCommand(binaryPath, ["--version"], { const { stdout } = await execCommand(binaryPath, ["--version"], {
...createProviderEnvSpec(), ...createProviderEnvSpec(),
timeout: 5_000, timeout: 5_000,
signal,
}); });
return stdout.trim() || "unknown"; return stdout.trim() || "unknown";
} catch (error) { } catch (error) {
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js";
import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js"; import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js";
const TEST_ACP_TIMEOUT_MS = 1_000; const TEST_ACP_TIMEOUT_MS = 1_000;
@@ -91,8 +92,15 @@ describe("GenericACPAgentClient diagnostics", () => {
}); });
await expect( await expect(
client.fetchCatalog({ cwd: tmpdir(), force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }), runProviderRefreshWithDeadline({
).rejects.toThrow(`ACP catalog probe timed out after ${TEST_ACP_TIMEOUT_MS}ms`); label: "Grok",
timeoutMs: TEST_ACP_TIMEOUT_MS,
operation: (context) =>
client.fetchCatalog({ scope: "workspace", cwd: tmpdir(), force: true }, context),
}),
).rejects.toThrow(
`Timed out refreshing Grok after ${TEST_ACP_TIMEOUT_MS}ms; pending: session/new`,
);
const pid = Number(await readFile(pidPath, "utf8")); const pid = Number(await readFile(pidPath, "utf8"));
await expectProcessExit(pid); await expectProcessExit(pid);
@@ -116,7 +124,7 @@ describe("GenericACPAgentClient diagnostics", () => {
}, },
}); });
await client.fetchCatalog({ cwd: testDir, force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }); await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true });
const session = await client.createSession({ provider: "acp", cwd: testDir }); const session = await client.createSession({ provider: "acp", cwd: testDir });
await session.close(); await session.close();
@@ -7,6 +7,7 @@ import type {
AgentSession, AgentSession,
AgentSessionConfig, AgentSessionConfig,
FetchCatalogOptions, FetchCatalogOptions,
ProviderRefreshContext,
ProviderCatalog, ProviderCatalog,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
@@ -24,10 +25,6 @@ const CAPABILITIES: AgentCapabilityFlags = {
supportsRewindBoth: false, supportsRewindBoth: false,
}; };
function neverResolves<T>(): Promise<T> {
return new Promise<T>(() => {});
}
export class MockSlowProviderClient implements AgentClient { export class MockSlowProviderClient implements AgentClient {
readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID; readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID;
readonly capabilities = CAPABILITIES; readonly capabilities = CAPABILITIES;
@@ -36,8 +33,19 @@ export class MockSlowProviderClient implements AgentClient {
return process.env.PASEO_ENABLE_MOCK_SLOW === "true"; return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
} }
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
return neverResolves<ProviderCatalog>(); _options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
return await (context?.runActivity(
"mock.catalog",
() =>
new Promise<ProviderCatalog>((_resolve, reject) => {
context.signal.addEventListener("abort", () => reject(context.signal.reason), {
once: true,
});
}),
) ?? new Promise<ProviderCatalog>(() => {}));
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -33,10 +33,12 @@ import {
type ImportProviderSessionInput, type ImportProviderSessionInput,
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
type ProviderCatalog, type ProviderCatalog,
type ProviderRefreshContext,
type ToolCallDetail, type ToolCallDetail,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js"; import type { PaseoToolCatalog } from "../../tools/types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js"; import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { runProviderTurn } from "../provider-runner.js"; import { runProviderTurn } from "../provider-runner.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
@@ -108,7 +110,6 @@ import {
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
const OMP_PROVIDER = "omp"; const OMP_PROVIDER = "omp";
const OMP_CATALOG_REQUEST_TIMEOUT_MS = 120_000;
const QUESTION_RESPONSE_HEADER = "Response"; const QUESTION_RESPONSE_HEADER = "Response";
const QUESTION_COMMENT_HEADER = "Comment"; const QUESTION_COMMENT_HEADER = "Comment";
const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
@@ -2304,23 +2305,44 @@ export class OmpAgentClient implements AgentClient {
} }
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const launchMode = this.resolveLaunchMode(undefined); const launchMode = this.resolveLaunchMode(undefined);
const runtimeSession = await this.runtime.startSession({ let runtimeSession: OmpRuntimeSession | undefined;
let closePromise: Promise<void> | undefined;
const closeSession = () => {
if (!runtimeSession) return Promise.resolve();
closePromise ??= runtimeSession.close();
return closePromise;
};
const handleAbort = () => void closeSession().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try {
await runProviderRefreshActivity(context, "runtime.start", async () => {
runtimeSession = await this.runtime.startSession({
cwd: options.scope === "global" ? homedir() : options.cwd, cwd: options.scope === "global" ? homedir() : options.cwd,
protocolMode: "rpc-ui", protocolMode: "rpc-ui",
modeId: launchMode.modeId, modeId: launchMode.modeId,
extraArgs: launchMode.extraArgs, extraArgs: launchMode.extraArgs,
signal: context?.signal,
}); });
try { if (context?.signal.aborted) await closeSession();
});
if (!runtimeSession) throw new Error("OMP catalog runtime did not start");
const catalogSession = runtimeSession;
const models = transformOmpModels( const models = transformOmpModels(
(await runtimeSession.getAvailableModels(OMP_CATALOG_REQUEST_TIMEOUT_MS)).map((model) => (
mapOmpModel(model, this.provider), await runProviderRefreshActivity(context, "get_available_models", () =>
), catalogSession.getAvailableModels(null),
)
).map((model) => mapOmpModel(model, this.provider)),
); );
return { models, modes: [...OMP_MODES] }; return { models, modes: [...OMP_MODES] };
} finally { } finally {
await runtimeSession.close(); context?.signal.removeEventListener("abort", handleAbort);
await closeSession();
} }
} }
@@ -88,13 +88,18 @@ export class OmpCliRuntime implements OmpRuntime {
...(spawn ? { spawn: () => spawn(launch) } : {}), ...(spawn ? { spawn: () => spawn(launch) } : {}),
}; };
const process = new JsonlRpcProcess(processOptions); const process = new JsonlRpcProcess(processOptions);
const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined);
input.signal?.addEventListener("abort", handleAbort, { once: true });
try { try {
await negotiateOmpProtocolV2(process, this.options.logger); await negotiateOmpProtocolV2(process, this.options.logger);
input.signal?.throwIfAborted();
return new OmpCliRuntimeSession(process, this.commandsRpcName); return new OmpCliRuntimeSession(process, this.commandsRpcName);
} catch (error) { } catch (error) {
const startupError = error instanceof Error ? error : new Error(String(error)); const startupError = error instanceof Error ? error : new Error(String(error));
await process.close(startupError); await process.close(startupError);
throw startupError; throw startupError;
} finally {
input.signal?.removeEventListener("abort", handleAbort);
} }
} }
} }
@@ -216,7 +221,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return data.messages ?? []; return data.messages ?? [];
} }
async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> { async getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]> {
const data = OmpModelsResultSchema.parse( const data = OmpModelsResultSchema.parse(
await this.request({ type: "get_available_models" }, timeoutMs), await this.request({ type: "get_available_models" }, timeoutMs),
); );
@@ -337,7 +342,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
await this.process.close(new Error("OMP RPC session is closed")); await this.process.close(new Error("OMP RPC session is closed"));
} }
private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> { private request(command: OmpRpcCommand, timeoutMs?: number | null): Promise<unknown> {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs); return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
} }
@@ -30,6 +30,7 @@ export interface OmpRuntimeLaunch {
export interface OmpStartSessionInput { export interface OmpStartSessionInput {
cwd: string; cwd: string;
signal?: AbortSignal;
env?: Record<string, string>; env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui"; protocolMode?: "rpc" | "rpc-ui";
model?: string; model?: string;
@@ -52,7 +53,7 @@ export interface OmpRuntimeSession {
abort(): Promise<void>; abort(): Promise<void>;
getState(): Promise<OmpSessionState>; getState(): Promise<OmpSessionState>;
getMessages(): Promise<OmpAgentMessage[]>; getMessages(): Promise<OmpAgentMessage[]>;
getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>; getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]>;
setModel(provider: string, modelId: string): Promise<OmpModel>; setModel(provider: string, modelId: string): Promise<OmpModel>;
setThinkingLevel(level: OmpThinkingLevel): Promise<void>; setThinkingLevel(level: OmpThinkingLevel): Promise<void>;
getSessionStats(): Promise<OmpSessionStats>; getSessionStats(): Promise<OmpSessionStats>;
@@ -274,7 +274,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.messages; return this.messages;
} }
async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> { async getAvailableModels(_timeoutMs?: number | null): Promise<OmpModel[]> {
return this.models; return this.models;
} }
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js";
import { import {
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeHarness, TestOpenCodeHarness,
@@ -11,32 +12,15 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { test("the catalog deadline aborts provider.list and releases the server", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const runtime = new TestOpenCodeHarness(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () => openCodeClient.providerListImplementation = (_parameters, options) =>
new Promise((resolve) => { new Promise((_resolve, reject) => {
setTimeout(() => { const signal = (options as { signal: AbortSignal }).signal;
resolve({ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
},
],
},
});
}, 15_000);
}); });
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
@@ -44,24 +28,72 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, createClient: runtime.createClient,
}); });
const modelsPromise = client.fetchCatalog({ const modelsPromise = runProviderRefreshWithDeadline({
scope: "workspace", label: "OpenCode",
cwd: "/tmp/opencode-models", timeoutMs: 100,
force: false, operation: (context) =>
client.fetchCatalog(
{ scope: "workspace", cwd: "/tmp/opencode-models", force: false },
context,
),
}); });
const rejection = expect(modelsPromise).rejects.toThrow(
"Timed out refreshing OpenCode after 100ms; pending: provider.list",
);
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(100);
await expect(modelsPromise).resolves.toMatchObject({ await rejection;
models: [
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
],
});
expect(openCodeClient.calls.providerList).toHaveLength(1); expect(openCodeClient.calls.providerList).toHaveLength(1);
expect(openCodeClient.calls.providerListOptions[0]).toMatchObject({
signal: expect.objectContaining({ aborted: true }),
});
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
});
test("the catalog deadline aborts app.agents and releases the server", async () => {
vi.useFakeTimers();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
};
openCodeClient.appAgentsImplementation = (_parameters, options) =>
new Promise((_resolve, reject) => {
const signal = (options as { signal: AbortSignal }).signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const catalogPromise = runProviderRefreshWithDeadline({
label: "OpenCode",
timeoutMs: 100,
operation: (context) =>
client.fetchCatalog(
{ scope: "workspace", cwd: "/tmp/opencode-agents", force: false },
context,
),
});
const rejection = expect(catalogPromise).rejects.toThrow(
"Timed out refreshing OpenCode after 100ms; pending: app.agents",
);
await vi.advanceTimersByTimeAsync(100);
await rejection;
expect(openCodeClient.calls.appAgents).toHaveLength(1);
expect(openCodeClient.calls.appAgentsOptions[0]).toMatchObject({
signal: expect.objectContaining({ aborted: true }),
});
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
}); });
test("uses a new server for explicit catalog refresh", async () => { test("uses a new server for explicit catalog refresh", async () => {
@@ -41,6 +41,7 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type FetchCatalogOptions, type FetchCatalogOptions,
type ProviderRefreshContext,
type ImportableProviderSession, type ImportableProviderSession,
type ImportProviderSessionContext, type ImportProviderSessionContext,
type ImportProviderSessionInput, type ImportProviderSessionInput,
@@ -53,6 +54,10 @@ import {
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import {
raceProviderRefreshAbort,
runProviderRefreshActivity,
} from "../provider-refresh-deadline.js";
import { import {
isDefaultAgentCreateConfigUnattended, isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig, resolveDefaultAgentCreateConfig,
@@ -68,6 +73,7 @@ import { execCommand } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
OpenCodeServerManager, OpenCodeServerManager,
type OpenCodeServerAcquisition,
type OpenCodeServerManagerLike, type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js"; } from "./opencode/server-manager.js";
import { resolveOpenCodeHomeDir } from "./opencode/paths.js"; import { resolveOpenCodeHomeDir } from "./opencode/paths.js";
@@ -297,9 +303,9 @@ type OpenCodeMcpConfig =
}; };
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_METADATA_CONCURRENCY = 4; const OPENCODE_METADATA_CONCURRENCY = 4;
const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY); const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY);
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{ {
name: "compact", name: "compact",
@@ -1422,14 +1428,22 @@ export class OpenCodeAgentClient implements AgentClient {
} }
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
const acquisition = options.force options: FetchCatalogOptions,
? await this.serverManager.acquireNew() context?: ProviderRefreshContext,
: await this.serverManager.acquireCurrent(); ): Promise<ProviderCatalog> {
let acquisition: OpenCodeServerAcquisition | undefined;
try {
await runProviderRefreshActivity(context, "server.acquire", async () => {
acquisition = options.force
? await this.serverManager.acquireNew(context?.signal)
: await this.serverManager.acquireCurrent(context?.signal);
});
if (!acquisition) throw new Error("OpenCode server acquisition did not complete");
context?.signal.throwIfAborted();
const { url } = acquisition.server; const { url } = acquisition.server;
const isGlobalCatalog = options.scope === "global"; const isGlobalCatalog = options.scope === "global";
try {
// OpenCode treats the catalog directory as a workspace. The global catalog // OpenCode treats the catalog directory as a workspace. The global catalog
// is not a project, so use the neutral OpenCode home instead of user home. // is not a project, so use the neutral OpenCode home instead of user home.
const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd; const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd;
@@ -1444,12 +1458,12 @@ export class OpenCodeAgentClient implements AgentClient {
const client = this.createOpenCodeClient({ baseUrl: url, directory }); const client = this.createOpenCodeClient({ baseUrl: url, directory });
const [models, modes] = await Promise.all([ const [models, modes] = await Promise.all([
this.fetchModelsFromClient(client, directory), this.fetchModelsFromClient(client, directory, context),
this.fetchModesFromClient(client, directory), this.fetchModesFromClient(client, directory, context),
]); ]);
return { models, modes }; return { models, modes };
} finally { } finally {
await acquisition.release(); await acquisition?.release();
} }
} }
@@ -1637,12 +1651,18 @@ export class OpenCodeAgentClient implements AgentClient {
private async fetchModelsFromClient( private async fetchModelsFromClient(
client: OpencodeClient, client: OpencodeClient,
directory: string, directory: string,
context?: ProviderRefreshContext,
): Promise<AgentModelDefinition[]> { ): Promise<AgentModelDefinition[]> {
const response = await openCodeMetadataLimit(() => const response = await runProviderRefreshActivity(context, "provider.list", () =>
withTimeout( raceProviderRefreshAbort(
client.provider.list({ directory }), context?.signal,
OPENCODE_PROVIDER_LIST_TIMEOUT_MS, openCodeMetadataLimit(() => {
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, context?.signal.throwIfAborted();
return client.provider.list(
{ directory },
context ? { signal: context.signal } : undefined,
);
}),
), ),
); );
@@ -1669,7 +1689,7 @@ export class OpenCodeAgentClient implements AgentClient {
} }
const models: AgentModelDefinition[] = []; const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear(); const contextWindows = new Map<string, number>();
for (const provider of providers.all) { for (const provider of providers.all) {
if (!isAccessible(provider)) { if (!isAccessible(provider)) {
continue; continue;
@@ -1679,7 +1699,7 @@ export class OpenCodeAgentClient implements AgentClient {
const definition = buildOpenCodeModelDefinition(provider, modelId, model); const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) { if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set( contextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId), buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens, contextWindowMaxTokens,
); );
@@ -1688,18 +1708,25 @@ export class OpenCodeAgentClient implements AgentClient {
} }
} }
context?.signal.throwIfAborted();
this.modelContextWindows.clear();
for (const [key, value] of contextWindows) this.modelContextWindows.set(key, value);
return models; return models;
} }
private async fetchModesFromClient( private async fetchModesFromClient(
client: OpencodeClient, client: OpencodeClient,
directory: string, directory: string,
context?: ProviderRefreshContext,
): Promise<AgentMode[]> { ): Promise<AgentMode[]> {
const response = await openCodeMetadataLimit(() => const response = await runProviderRefreshActivity(context, "app.agents", () =>
withTimeout( raceProviderRefreshAbort(
client.app.agents({ directory }), context?.signal,
10_000, openCodeMetadataLimit(() => {
"OpenCode app.agents timed out after 10s", context?.signal.throwIfAborted();
return client.app.agents({ directory }, context ? { signal: context.signal } : undefined);
}),
), ),
); );
@@ -138,6 +138,25 @@ describe("OpenCodeServerManager generations", () => {
expect(await runtime.managedProcesses.list()).toEqual([]); expect(await runtime.managedProcesses.list()).toEqual([]);
}); });
test("aborted acquisition transfers no reference and leaves startup reusable", async () => {
const { manager, runtime } = createTestManager([4477], { autoAnnounce: false });
const controller = new AbortController();
const abortedAcquisition = manager.acquireCurrent(controller.signal);
await runtime.settle();
controller.abort(new Error("catalog refresh expired"));
await expect(abortedAcquisition).rejects.toThrow("catalog refresh expired");
runtime.processForPort(4477).announceListening();
const nextAcquisition = await manager.acquireCurrent();
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4477");
expect(runtime.launchedPorts).toEqual([4477]);
await nextAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4477]);
});
test("shutdown kills a server that is still starting", async () => { test("shutdown kills a server that is still starting", async () => {
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false }); const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
@@ -25,8 +25,8 @@ export interface OpenCodeServerAcquisition {
} }
export interface OpenCodeServerManagerLike { export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>; acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>; acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>; acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null; acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>; shutdown(): Promise<void>;
@@ -137,13 +137,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
process.on("SIGINT", cleanup); process.on("SIGINT", cleanup);
} }
async acquireCurrent(): Promise<OpenCodeServerAcquisition> { async acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> {
const server = await this.getCurrentServer(); signal?.throwIfAborted();
const server = await waitForServerAcquisition(this.getCurrentServer(), signal);
signal?.throwIfAborted();
return this.acquireServer(server); return this.acquireServer(server);
} }
async acquireNew(): Promise<OpenCodeServerAcquisition> { async acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> {
const server = await this.getNewServer(); signal?.throwIfAborted();
const server = await waitForServerAcquisition(this.getNewServer(), signal);
signal?.throwIfAborted();
return this.acquireServer(server); return this.acquireServer(server);
} }
@@ -533,6 +537,23 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
} }
} }
async function waitForServerAcquisition<T>(
operation: Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
if (!signal) return await operation;
let handleAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
handleAbort = () => reject(signal.reason);
signal.addEventListener("abort", handleAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (handleAbort) signal.removeEventListener("abort", handleAbort);
}
}
async function resolveOpenCodeBinary(): Promise<string> { async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode"); const found = await findExecutable("opencode");
if (!found) { if (!found) {
@@ -71,6 +71,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
export class TestOpenCodeClient { export class TestOpenCodeClient {
readonly calls = { readonly calls = {
appAgents: [] as unknown[], appAgents: [] as unknown[],
appAgentsOptions: [] as unknown[],
commandList: [] as unknown[], commandList: [] as unknown[],
eventSubscribe: [] as unknown[], eventSubscribe: [] as unknown[],
experimentalSessionList: [] as unknown[], experimentalSessionList: [] as unknown[],
@@ -79,6 +80,7 @@ export class TestOpenCodeClient {
mcpConnect: [] as unknown[], mcpConnect: [] as unknown[],
permissionReply: [] as unknown[], permissionReply: [] as unknown[],
providerList: [] as unknown[], providerList: [] as unknown[],
providerListOptions: [] as unknown[],
questionReject: [] as unknown[], questionReject: [] as unknown[],
questionReply: [] as unknown[], questionReply: [] as unknown[],
sessionAbort: [] as unknown[], sessionAbort: [] as unknown[],
@@ -95,6 +97,9 @@ export class TestOpenCodeClient {
}; };
appAgentsResponse: OpenCodeResponse = { data: [] }; appAgentsResponse: OpenCodeResponse = { data: [] };
appAgentsImplementation:
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
| null = null;
commandListResponse: OpenCodeResponse = { data: [] }; commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown>; eventStream: AsyncIterable<unknown>;
experimentalSessionListResponse: OpenCodeResponse = { data: [] }; experimentalSessionListResponse: OpenCodeResponse = { data: [] };
@@ -102,7 +107,9 @@ export class TestOpenCodeClient {
mcpConnectResponse: OpenCodeResponse = {}; mcpConnectResponse: OpenCodeResponse = {};
permissionReplyResponse: OpenCodeResponse = {}; permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } }; providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null; providerListImplementation:
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
| null = null;
globalEventImplementation: globalEventImplementation:
| ((options: unknown) => Promise<{ stream: AsyncIterable<unknown> }>) | ((options: unknown) => Promise<{ stream: AsyncIterable<unknown> }>)
| null = null; | null = null;
@@ -141,8 +148,12 @@ export class TestOpenCodeClient {
asSdkClient(): OpencodeClient { asSdkClient(): OpencodeClient {
return { return {
app: { app: {
agents: async (parameters: unknown) => { agents: async (parameters: unknown, options: unknown) => {
this.calls.appAgents.push(parameters); this.calls.appAgents.push(parameters);
this.calls.appAgentsOptions.push(options);
if (this.appAgentsImplementation) {
return await this.appAgentsImplementation(parameters, options);
}
return this.appAgentsResponse; return this.appAgentsResponse;
}, },
}, },
@@ -195,10 +206,11 @@ export class TestOpenCodeClient {
}, },
}, },
provider: { provider: {
list: async (parameters: unknown) => { list: async (parameters: unknown, options: unknown) => {
this.calls.providerList.push(parameters); this.calls.providerList.push(parameters);
this.calls.providerListOptions.push(options);
return this.providerListImplementation return this.providerListImplementation
? await this.providerListImplementation() ? await this.providerListImplementation(parameters, options)
: this.providerListResponse; : this.providerListResponse;
}, },
}, },
@@ -36,9 +36,11 @@ import {
type ImportProviderSessionInput, type ImportProviderSessionInput,
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
type ProviderCatalog, type ProviderCatalog,
type ProviderRefreshContext,
type ToolCallDetail, type ToolCallDetail,
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js"; import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { runProviderTurn } from "../provider-runner.js"; import { runProviderTurn } from "../provider-runner.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
@@ -88,7 +90,6 @@ import {
const PI_PROVIDER = "pi"; const PI_PROVIDER = "pi";
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
const PI_CATALOG_REQUEST_TIMEOUT_MS = 120_000;
const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree"; const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
@@ -2474,19 +2475,40 @@ export class PiRpcAgentClient implements AgentClient {
} }
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { async fetchCatalog(
const runtimeSession = await this.runtime.startSession({ options: FetchCatalogOptions,
cwd: options.scope === "global" ? homedir() : options.cwd, context?: ProviderRefreshContext,
}); ): Promise<ProviderCatalog> {
let runtimeSession: PiRuntimeSession | undefined;
let closePromise: Promise<void> | undefined;
const closeSession = () => {
if (!runtimeSession) return Promise.resolve();
closePromise ??= runtimeSession.close();
return closePromise;
};
const handleAbort = () => void closeSession().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try { try {
await runProviderRefreshActivity(context, "runtime.start", async () => {
runtimeSession = await this.runtime.startSession({
cwd: options.scope === "global" ? homedir() : options.cwd,
signal: context?.signal,
});
if (context?.signal.aborted) await closeSession();
});
if (!runtimeSession) throw new Error("Pi catalog runtime did not start");
const catalogSession = runtimeSession;
const models = transformPiModels( const models = transformPiModels(
(await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map((model) => (
mapPiModel(model, PI_PROVIDER), await runProviderRefreshActivity(context, "get_available_models", () =>
), catalogSession.getAvailableModels(null),
)
).map((model) => mapPiModel(model, PI_PROVIDER)),
); );
return { models, modes: [] }; return { models, modes: [] };
} finally { } finally {
await runtimeSession.close(); context?.signal.removeEventListener("abort", handleAbort);
await closeSession();
} }
} }
@@ -78,6 +78,10 @@ export class PiCliRuntime implements PiRuntime {
...(spawn ? { spawn: () => spawn(launch) } : {}), ...(spawn ? { spawn: () => spawn(launch) } : {}),
}; };
const process = new JsonlRpcProcess(processOptions); const process = new JsonlRpcProcess(processOptions);
if (input.signal?.aborted) {
await process.close(input.signal.reason);
input.signal.throwIfAborted();
}
return new PiCliRuntimeSession(process, this.commandsRpcName); return new PiCliRuntimeSession(process, this.commandsRpcName);
} }
} }
@@ -26,6 +26,7 @@ export interface PiRuntimeLaunch {
export interface PiStartSessionInput { export interface PiStartSessionInput {
cwd: string; cwd: string;
signal?: AbortSignal;
env?: Record<string, string>; env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui"; protocolMode?: "rpc" | "rpc-ui";
model?: string; model?: string;
+2
View File
@@ -427,6 +427,7 @@ export interface PaseoDaemonConfig {
dictationFinalTimeoutMs?: number; dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number; downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap; agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerCatalogRefreshTimeoutMs?: number;
metadataGeneration?: { metadataGeneration?: {
providers?: Array<{ providers?: Array<{
provider: string; provider: string;
@@ -820,6 +821,7 @@ export async function createPaseoDaemon(
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
const providerSnapshotManager = new ProviderSnapshotManager({ const providerSnapshotManager = new ProviderSnapshotManager({
logger: providerSnapshotLogger, logger: providerSnapshotLogger,
refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs,
runtimeSettings: config.agentProviderSettings, runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides, providerOverrides: config.providerOverrides,
workspaceGitService, workspaceGitService,
+14 -1
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
@@ -26,6 +26,19 @@ describe("server config", () => {
expect(standaloneConfig.desktopManaged).toBe(false); expect(standaloneConfig.desktopManaged).toBe(false);
}); });
test("loads the provider catalog refresh timeout", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
roots.push(paseoHome);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ agents: { catalogRefreshTimeoutMs: 180_000 } }),
);
const config = loadConfig(paseoHome, { env: {} });
expect(config.providerCatalogRefreshTimeoutMs).toBe(180_000);
});
test("resolves bundled web UI path from source-tree modules", () => { test("resolves bundled web UI path from source-tree modules", () => {
const root = path.parse(process.cwd()).root; const root = path.parse(process.cwd()).root;
expect( expect(
+1
View File
@@ -555,6 +555,7 @@ export function loadConfig(
voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model, voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides), agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration, metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides, providerOverrides,
log: resolveLogConfigFromEnv(env, persisted), log: resolveLogConfigFromEnv(env, persisted),
@@ -237,7 +237,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await expect await expect
.poll(() => paths.filter((path) => !observed.has(path)), { timeout: 10_000 }) .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 })
.toEqual([]); .toEqual([]);
const removedPaths = paths.slice(0, 100); const removedPaths = paths.slice(0, 100);
@@ -258,7 +258,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await writeFile(sentinel, "alive"); await writeFile(sentinel, "alive");
await expect.poll(() => observed.has(sentinel)).toBe(true); await expect.poll(() => observed.has(sentinel)).toBe(true);
await subscription.unsubscribe(); await subscription.unsubscribe();
}, 30_000); }, 90_000);
test("unsubscribe cancels reconciliation queued by a write burst", async () => { test("unsubscribe cancels reconciliation queued by a write burst", async () => {
const root = await createRoot(); const root = await createRoot();
@@ -251,6 +251,20 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
], ],
}); });
}); });
test("accepts a custom provider catalog refresh timeout", () => {
const parsed = PersistedConfigSchema.parse({
agents: { catalogRefreshTimeoutMs: 180_000 },
});
expect(parsed.agents?.catalogRefreshTimeoutMs).toBe(180_000);
});
test("rejects provider catalog refresh timeouts that overflow Node timers", () => {
expect(() =>
PersistedConfigSchema.parse({ agents: { catalogRefreshTimeoutMs: 2_147_483_648 } }),
).toThrow();
});
}); });
describe("provider overrides (new format)", () => { describe("provider overrides (new format)", () => {
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
agents: z agents: z
.object({ .object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(),
}) })
.strict() .strict()
+2
View File
@@ -27,6 +27,7 @@ interface ExecCommandOptions extends ExternalEnvOptions {
timeout?: number; timeout?: number;
maxBuffer?: number; maxBuffer?: number;
shell?: boolean | string; shell?: boolean | string;
signal?: AbortSignal;
} }
interface ExecCommandResult { interface ExecCommandResult {
@@ -77,6 +78,7 @@ export function spawnProcess(
...spawnOptions, ...spawnOptions,
env: childEnv, env: childEnv,
shell, shell,
signal: options?.signal,
windowsHide: true, windowsHide: true,
}); });
} }
@@ -335,6 +335,11 @@
}, },
"additionalProperties": false "additionalProperties": false
} }
},
"catalogRefreshTimeoutMs": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 2147483647
} }
}, },
"additionalProperties": false "additionalProperties": false