feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346)

* feat(server): make provider refresh timeout configurable

Provider refresh probes (isAvailable + listModels/listModes) were hardcoded
to a 30s timeout. With many providers and MCP servers configured, cold
starts of agents like Copilot can exceed this on the first probe, surfacing
'Timed out refreshing Copilot after 30000ms' even though a manual retry
succeeds (warm caches).

Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the
ceiling without rebuilding. The explicit constructor option still wins
over the env var, and invalid values fall back to the 30s default.

* refactor(server): address review feedback on refresh timeout config

- Use Number() instead of Number.parseInt() so scientific notation like
  '6e4' parses as 60000 instead of being silently truncated to 6.
- Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the
  suite and avoid manual process.env save/restore.
This commit is contained in:
Fabian Fischer
2026-06-05 14:19:05 +02:00
committed by GitHub
parent 45cca8a406
commit dfddda7969
2 changed files with 104 additions and 1 deletions

View File

@@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => {
// never-resolving isAvailable forces the timeout path
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 1,
providerOverrides: {
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
});
try {
const entry = await manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
expect(entry.provider).toBe("codex");
expect(entry.status).toBe("error");
expect(entry.error).toMatch(/after 1ms/);
} finally {
manager.destroy();
}
});
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
});
try {
const entry = await manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
expect(entry.status).toBe("error");
expect(entry.error).toMatch(/after 1ms/);
} finally {
manager.destroy();
vi.unstubAllEnvs();
}
});
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 5,
providerOverrides: {
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: { codex: createExtraClient("codex", { isAvailable }) },
});
try {
const entry = await manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
expect(entry.status).toBe("error");
// explicit option (5) wins over env var (1)
expect(entry.error).toMatch(/after 5ms/);
} finally {
manager.destroy();
vi.unstubAllEnvs();
}
});
test("listProviders returns an entry per registered provider", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),

View File

@@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js
import type { MutableDaemonConfig } from "../daemon-config-store.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
// Provider refresh probes can be slow on cold starts (e.g. Copilot's first
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
// Allow operators to bump the ceiling via env var without rebuilding.
function resolveRefreshTimeoutMs(option: number | undefined): number {
if (typeof option === "number" && Number.isFinite(option) && option > 0) {
return option;
}
const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR];
if (fromEnv) {
// Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate.
const parsed = Number(fromEnv);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return DEFAULT_REFRESH_TIMEOUT_MS;
}
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
@@ -124,7 +143,7 @@ export class ProviderSnapshotManager {
this.runtimeSettings = options.runtimeSettings;
this.providerOverrides = options.providerOverrides;
this.baseProviderOverrides = options.providerOverrides;
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
}