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
68 changes: 34 additions & 34 deletions vscode-agentchatbus/out/extension.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions vscode-agentchatbus/out/logic/testExports.js

Large diffs are not rendered by default.

136 changes: 115 additions & 21 deletions vscode-agentchatbus/src/busServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import {
buildBundledLaunchSpec,
buildWorkspaceDevLaunchSpec,
classifyDetectedStartupMode,
createSingleFlightRunner,
ensureSupportedHostNodeVersion,
extractOwnershipAssignable,
normalizeHealthString,
resolveStartupProbeResult,
type BundledLaunchSpec as LaunchSpec,
type HealthPayload,
type MetricsPayload,
type LaunchMode,
type StartupProbeEndpoint,
type StartupProbeOutcome,
type StartupProbeResolution,
} from './logic/busServerManager';
import {
resolveWorkspaceDevContext,
Expand Down Expand Up @@ -54,6 +60,7 @@ export class BusServerManager {
private static readonly MCP_PROVIDER_ID = 'agentchatbus.provider';
private static readonly MCP_PROVIDER_LABEL = 'AgentChatBus Local Server';
private static readonly MAX_ATTEMPTS = 40;
private static readonly STARTUP_PROBE_TIMEOUT_MS = 2000;

private outputChannel: vscode.OutputChannel;
private serverProcess: child_process.ChildProcess | null = null;
Expand All @@ -72,6 +79,7 @@ export class BusServerManager {
private readonly globalStoragePath: string;
private readonly hostNodeExecutable: string;
private readonly extensionVersion: string;
private readonly ensureServerRunningSingleFlight: () => Promise<boolean>;
private ideSessionToken: string | null = null;
private ownerBootToken: string | null = null;
private ideSessionState: IdeSessionApiState = {
Expand All @@ -89,6 +97,7 @@ export class BusServerManager {
this.globalStoragePath = context.globalStorageUri.fsPath;
this.hostNodeExecutable = process.execPath;
this.extensionVersion = String(context.extension.packageJSON?.version || 'unknown');
this.ensureServerRunningSingleFlight = createSingleFlightRunner(() => this.ensureServerRunningInternal());
this.outputChannel = vscode.window.createOutputChannel('AgentChatBus Server');
void vscode.commands.executeCommand('setContext', 'agentchatbus:serverStopping', false);
this.updateRestartContexts();
Expand Down Expand Up @@ -219,6 +228,10 @@ export class BusServerManager {
}

async ensureServerRunning(): Promise<boolean> {
return this.ensureServerRunningSingleFlight();
}

private async ensureServerRunningInternal(): Promise<boolean> {
const workspaceDevContext = this.getWorkspaceDevContext();
if (workspaceDevContext) {
this.log(
Expand All @@ -242,17 +255,27 @@ export class BusServerManager {
try {
const probe = await this.probeServer(serverUrl);
if (probe.ok) {
const startupMode = classifyDetectedStartupMode(probe.health);
if (probe.source === 'metrics' && probe.failureMessages.length > 0) {
for (const message of probe.failureMessages) {
this.recordResolutionAttempt(message);
}
this.recordResolutionAttempt('Readiness fell back to /api/metrics because /health was unavailable.');
}
const startupMode = classifyDetectedStartupMode(probe.payload as HealthPayload);
this.serverMetadata.startupMode = startupMode;
this.serverMetadata.resolvedBy = 'Existing service detected via /health';
this.serverMetadata.backendEngine = normalizeHealthString(probe.health?.engine);
this.serverMetadata.backendVersion = normalizeHealthString(probe.health?.version);
this.serverMetadata.backendRuntime = normalizeHealthString(probe.health?.runtime);
this.serverMetadata.resolvedBy = probe.source === 'metrics'
? 'Existing service detected via /api/metrics fallback'
: 'Existing service detected via /health';
this.serverMetadata.backendEngine = normalizeHealthString(probe.payload?.engine);
this.serverMetadata.backendVersion = normalizeHealthString(probe.payload?.version);
this.serverMetadata.backendRuntime = normalizeHealthString(probe.payload?.runtime);
this.serverMetadata.externalOwnershipAssignable =
extractOwnershipAssignable(probe.health);
extractOwnershipAssignable(probe.payload as HealthPayload);
this.ownerBootToken = null;
this.recordResolutionAttempt(
`Detected an already-running AgentChatBus service via /health probe (mode=${startupMode}).`
probe.source === 'metrics'
? `Detected an already-running AgentChatBus service via /api/metrics fallback (mode=${startupMode}).`
: `Detected an already-running AgentChatBus service via /health probe (mode=${startupMode}).`
);
if (this.serverMetadata.backendEngine || this.serverMetadata.backendVersion) {
this.recordResolutionAttempt(
Expand Down Expand Up @@ -281,6 +304,9 @@ export class BusServerManager {
this.setServerReady(true);
return true;
}
for (const message of probe.failureMessages) {
this.recordResolutionAttempt(message);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log(`Probe failed: ${message}`, 'warning');
Expand Down Expand Up @@ -880,27 +906,65 @@ export class BusServerManager {
);
}

private async probeServer(url: string): Promise<{ ok: boolean; health?: HealthPayload }> {
private async probeJsonEndpoint<T>(
url: string,
endpoint: StartupProbeEndpoint,
): Promise<StartupProbeOutcome<T>> {
const probePath = endpoint === 'health' ? '/health' : '/api/metrics';
const controller = new AbortController();
const timeoutMs = BusServerManager.STARTUP_PROBE_TIMEOUT_MS;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000);
const response = await fetch(`${url}/health`, { signal: controller.signal });
clearTimeout(timeoutId);
const response = await fetch(`${url}${probePath}`, { signal: controller.signal });
if (!response.ok) {
return { ok: false };
return {
ok: false,
status: response.status,
timeoutMs,
};
}
let health: HealthPayload | undefined;

let payload: T | undefined;
try {
health = await response.json() as HealthPayload;
payload = await response.json() as T;
} catch {
// Allow legacy health endpoints that return non-JSON.
// Allow legacy or partial probe endpoints that return non-JSON.
}
return { ok: true, health };
} catch {
return { ok: false };

return {
ok: true,
payload,
};
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return {
ok: false,
timedOut: true,
timeoutMs,
};
}

return {
ok: false,
error: error instanceof Error ? error.message : String(error),
timeoutMs,
};
} finally {
clearTimeout(timeoutId);
}
}

private async probeServer(url: string): Promise<StartupProbeResolution<HealthPayload | MetricsPayload>> {
const health = await this.probeJsonEndpoint<HealthPayload>(url, 'health');
if (health.ok) {
return resolveStartupProbeResult({ health });
}

const metrics = await this.probeJsonEndpoint<MetricsPayload>(url, 'metrics');
return resolveStartupProbeResult({ health, metrics });
}

private async checkServer(url: string): Promise<boolean> {
const probe = await this.probeServer(url);
return probe.ok;
Expand Down Expand Up @@ -1201,13 +1265,43 @@ export class BusServerManager {
this.log('Waiting for health check response...', 'sync~spin');
const serverUrl = this.getServerUrl();
let retries = 20;
let lastProbeFailureSignature = '';
while (retries > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
if (await this.checkServer(serverUrl)) {
this.log('Server is online and ready.', 'check');
const probe = await this.probeServer(serverUrl);
if (probe.ok) {
if (probe.source === 'metrics' && probe.failureMessages.length > 0) {
for (const message of probe.failureMessages) {
this.log(message, 'warning');
}
this.log('Readiness fell back to /api/metrics because /health was unavailable.', 'info');
}
this.serverMetadata.backendEngine =
normalizeHealthString(probe.payload?.engine) || this.serverMetadata.backendEngine;
this.serverMetadata.backendVersion =
normalizeHealthString(probe.payload?.version) || this.serverMetadata.backendVersion;
this.serverMetadata.backendRuntime =
normalizeHealthString(probe.payload?.runtime) || this.serverMetadata.backendRuntime;
this.serverMetadata.externalOwnershipAssignable =
extractOwnershipAssignable(probe.payload as HealthPayload) ?? this.serverMetadata.externalOwnershipAssignable;
this.log(
probe.source === 'metrics'
? 'Server is online and ready via /api/metrics fallback.'
: 'Server is online and ready via /health.',
'check',
);
await this.ensureIdeSessionRegistered(true);
return true;
}

const failureSignature = probe.failureMessages.join(' | ');
if (failureSignature && failureSignature !== lastProbeFailureSignature) {
for (const message of probe.failureMessages) {
this.log(message, 'warning');
}
lastProbeFailureSignature = failureSignature;
}

retries--;
}

Expand Down
110 changes: 110 additions & 0 deletions vscode-agentchatbus/src/logic/busServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ export type HealthPayload = {
};
};

export type MetricsPayload = HealthPayload & Record<string, unknown>;

export type StartupProbeEndpoint = 'health' | 'metrics';

export type StartupProbeFailure = {
status?: number;
timedOut?: boolean;
timeoutMs?: number;
error?: string;
};

export type StartupProbeOutcome<T> =
| {
ok: true;
payload?: T;
}
| ({
ok: false;
} & StartupProbeFailure);

export type StartupProbeResolution<T> = {
ok: boolean;
source: StartupProbeEndpoint | null;
payload?: T;
failureMessages: string[];
};

export const MIN_HOST_NODE_VERSION = {
major: 20,
minor: 0,
Expand All @@ -43,6 +70,10 @@ export const BUNDLED_RUNTIME_RESOLVED_BY =
export const WORKSPACE_DEV_RUNTIME_RESOLVED_BY =
'Workspace-dev agentchatbus-ts runtime and local web-ui sources from the current AgentChatBus repo.';

function getStartupProbePath(endpoint: StartupProbeEndpoint): string {
return endpoint === 'health' ? '/health' : '/api/metrics';
}

export function normalizeHealthString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
Expand Down Expand Up @@ -127,6 +158,85 @@ export function ensureSupportedHostNodeVersion(
};
}

export function describeStartupProbeFailure(
endpoint: StartupProbeEndpoint,
failure: StartupProbeFailure,
): string {
const probePath = getStartupProbePath(endpoint);

if (failure.timedOut) {
return `Startup probe ${probePath} timed out after ${failure.timeoutMs ?? 'unknown'}ms.`;
}
if (typeof failure.status === 'number') {
return `Startup probe ${probePath} returned HTTP ${failure.status}.`;
}
if (failure.error) {
return `Startup probe ${probePath} failed: ${failure.error}.`;
}
return `Startup probe ${probePath} failed.`;
}

export function resolveStartupProbeResult(input: {
health: StartupProbeOutcome<HealthPayload>;
metrics?: StartupProbeOutcome<MetricsPayload>;
}): StartupProbeResolution<HealthPayload | MetricsPayload> {
if (input.health.ok) {
return {
ok: true,
source: 'health',
payload: input.health.payload,
failureMessages: [],
};
}

const failureMessages = [describeStartupProbeFailure('health', input.health)];

if (input.metrics?.ok) {
return {
ok: true,
source: 'metrics',
payload: input.metrics.payload,
failureMessages,
};
}

if (input.metrics && !input.metrics.ok) {
failureMessages.push(describeStartupProbeFailure('metrics', input.metrics));
}

return {
ok: false,
source: null,
payload: undefined,
failureMessages,
};
}

export function createSingleFlightRunner<T>(operation: () => Promise<T>): () => Promise<T> {
let inFlight: Promise<T> | null = null;

return () => {
if (inFlight) {
return inFlight;
}

let execution: Promise<T>;
try {
execution = Promise.resolve(operation());
} catch (error) {
execution = Promise.reject(error);
}
const wrapped = execution.finally(() => {
if (inFlight === wrapped) {
inFlight = null;
}
});

inFlight = wrapped;
return wrapped;
};
}

export function buildBundledLaunchSpec(input: {
serverEntry: string;
webUiDir: string;
Expand Down
3 changes: 3 additions & 0 deletions vscode-agentchatbus/src/logic/testExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ export {
buildWorkspaceDevLaunchSpec,
classifyDetectedStartupMode,
classifyExternalStartupMode,
createSingleFlightRunner,
describeStartupProbeFailure,
ensureSupportedHostNodeVersion,
extractOwnershipAssignable,
normalizeHealthString,
resolveStartupProbeResult,
WORKSPACE_DEV_RUNTIME_RESOLVED_BY,
} from './busServerManager';
export {
Expand Down
Loading
Loading