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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,14 @@ const COPILOT_DISCOVERY_BATCH_SIZE = 250;
*/
const CHAT_DISCOVERY_RETRY_DELAYS_MS = [250, 1_000, 5_000];

/**
* How many times `_ensureClient` re-acquires the SDK client after a cold-start
* abort caused by a startup-config change. One extra attempt covers the common
* one-time startup settle observed in the field; the bound prevents livelock if
* the config keeps changing on every start.
*/
const MAX_STARTUP_CONFIG_RETRIES = 1;

/** `origin` value written by the VS Code extension-host Copilot CLI feature. */
const EXTENSION_HOST_CLI_MARKER_ORIGIN = 'vscode';

Expand Down Expand Up @@ -772,6 +780,12 @@ export class CopilotAgent extends Disposable implements IAgent {

private _client: CopilotClient | undefined;
private _clientStarting: Promise<CopilotClient> | undefined;
/**
* Coalesces the whole acquire-and-self-heal sequence in `_ensureClient` so
* that all concurrent callers share a single, global retry budget for
* startup-config-changed aborts (rather than each caller getting its own).
*/
private _ensureClientHealing: Promise<CopilotClient> | undefined;
private _clientStopping: Promise<void> | undefined;
private _clientStartupAttemptCount = 0;
private _resolvedProxy: string | undefined;
Expand Down Expand Up @@ -1895,7 +1909,57 @@ export class CopilotAgent extends Disposable implements IAgent {
throw terminalError;
}

private async _ensureClient(): Promise<CopilotClient> {
/**
* Acquires the SDK client, transparently self-healing a single cold-start
* abort caused by a startup-config change (`CopilotClientStartupConfigChangedError`).
* That abort is transient: the superseded client was built with now-stale
* config and the next start uses the current config, so re-acquiring once
* returns a healthy client and no caller ever sees the abort.
*
* The re-acquire is bounded by {@link MAX_STARTUP_CONFIG_RETRIES}. All
* concurrent callers share one acquire-and-retry sequence via
* `_ensureClientHealing`, so the retry budget is global rather than per
* caller (a late caller cannot reset the budget and drive unbounded starts).
* The per-attempt coalescing in `_ensureClientOnce` (via `_clientStarting`) is
* unchanged.
*/
private _ensureClient(): Promise<CopilotClient> {
if (this._ensureClientHealing) {
return this._ensureClientHealing;
}
const healing = (async () => {
try {
for (let retries = 0; ; retries++) {
try {
return await this._ensureClientOnce();
} catch (error) {
if (retries < MAX_STARTUP_CONFIG_RETRIES
&& !this._shutdownPromise
&& error instanceof CopilotClientStartupConfigChangedError) {
this._logService.info('[Copilot] Startup config changed while the client was starting; re-acquiring the client with the current config');
continue;
}
throw error;
}
}
} finally {
// Clear the shared handle from inside the sequence so it is gone
// before this promise settles for any awaiting caller. Clearing it
// from a trailing `.finally()` on a separate chain would run one
// microtask too late: a caller resuming on success could re-enter
// `_ensureClient` (e.g. after `_stopClient()`) and be handed this
// fulfilled handle for an already-stopped client. Only one healing
// sequence is ever in flight — `_ensureClient` starts one only when
// the field is empty, and this is the only site that clears it — so
// this always owns the field here.
this._ensureClientHealing = undefined;
}
})();
this._ensureClientHealing = healing;
return healing;
}

private async _ensureClientOnce(): Promise<CopilotClient> {
if (this._shutdownPromise) {
throw new CancellationError();
}
Expand Down Expand Up @@ -4566,6 +4630,14 @@ export class CopilotAgent extends Disposable implements IAgent {

// ---- helpers ------------------------------------------------------------

/**
* Returns the effective Kerberos proxy SPN, treating an empty setting as absent.
*/
private _readKerberosSpn(env: Record<string, string | undefined>): string | undefined {
const spn = env['COPILOT_PROXY_KERBEROS_SPN'] || this._configurationService.getRootValue(agentHostProxyConfigSchema, AgentHostProxyConfigKey.ProxyKerberosServicePrincipal);
return spn || undefined;
}

private _applyProxyEnv(env: Record<string, string | undefined>): void {
const proxy = this._isSystemProxyEnabled() ? this._resolvedProxy : undefined;
this._appliedProxy = proxy;
Expand All @@ -4575,7 +4647,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
this._logService.info('[Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK');
}
const kerberosSpn = env['COPILOT_PROXY_KERBEROS_SPN'] || this._configurationService.getRootValue(agentHostProxyConfigSchema, AgentHostProxyConfigKey.ProxyKerberosServicePrincipal);
const kerberosSpn = this._readKerberosSpn(env);
this._appliedProxyKerberosSpn = kerberosSpn;
if (kerberosSpn && !env['COPILOT_PROXY_KERBEROS_SPN']) {
env['COPILOT_PROXY_KERBEROS_SPN'] = kerberosSpn;
Expand Down Expand Up @@ -4619,7 +4691,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
this._resolvedProxy = proxy;
const effectiveProxy = this._isSystemProxyEnabled() ? proxy : undefined;
const effectiveKerberosSpn = process.env['COPILOT_PROXY_KERBEROS_SPN'] || this._configurationService.getRootValue(agentHostProxyConfigSchema, AgentHostProxyConfigKey.ProxyKerberosServicePrincipal);
const effectiveKerberosSpn = this._readKerberosSpn(process.env);
if (effectiveProxy === this._appliedProxy && effectiveKerberosSpn === this._appliedProxyKerberosSpn) {
return;
}
Expand All @@ -4636,7 +4708,14 @@ export class CopilotAgent extends Disposable implements IAgent {
return;
}
}
await this._requestClientRestart(`CAPI proxy configuration changed (${this._appliedProxy ?? '(none)'} -> ${effectiveProxy ?? '(none)'})`);
const changes: string[] = [];
if (effectiveProxy !== this._appliedProxy) {
changes.push(`proxy ${this._appliedProxy ?? '(none)'} -> ${effectiveProxy ?? '(none)'}`);
}
if (effectiveKerberosSpn !== this._appliedProxyKerberosSpn) {
changes.push('Kerberos SPN changed');
}
await this._requestClientRestart(`CAPI proxy configuration changed (${changes.join(', ')})`);
}).catch(error => this._logService.error('[Copilot] Failed to refresh CAPI proxy', error));
this._proxyRefresh = refresh;
void refresh.finally(() => {
Expand Down
Loading
Loading