pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
706 lines (652 loc) • 26.2 kB
text/typescript
/**
* Session config-reload and dynamic `mcp_servers_change` lifecycle state machine.
*
* Owns untrusted-safe bootstrap config loading, trust-gated `session_start`
* reloads, serialized `mcp_servers_change` reconciliation, eager-start
* supersession, and session teardown coordination.
*/
import type {
ExtensionAPI,
ExtensionContext,
RegisteredMcpServer,
} from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
import {
loadConfigWithOrigins,
parseRegisteredServerConfig,
type LoadedConfig,
type McpConfig,
type ServerConfig,
} from "./config.js";
import {
withExtensionServers,
type ConfigProvenance,
type ConfigProvenanceInput,
} from "./server-provenance.js";
import type { ServerManager } from "./server-manager.js";
import type { ToolBridge } from "./tool-bridge.js";
import type { ResourceBridge } from "./resource-bridge.js";
import type { ConnectionCoordinator } from "./connection-coordinator.js";
import { formatErrorMessage } from "./startup-auth.js";
import { describeError } from "./debug-log-format.js";
import {
getDebugLog,
reportDiagnostic,
setDiagnosticUi,
} from "./debug-log.js";
export const DEFAULT_SETTINGS = {
toolPrefix: "mcp",
requestTimeoutMs: 30000,
maxRetries: 5,
} as const;
export type SessionProvenance = ConfigProvenance;
export interface BootstrapConfigPaths {
bootstrapCwd?: string | undefined;
globalConfigPath?: string | undefined;
}
export interface BootstrapConfigState {
fileConfig: McpConfig;
fileProvenance: ConfigProvenanceInput;
extensionServers: Record<string, ServerConfig>;
config: McpConfig;
}
export interface SessionLifecycleDeps {
pi: ExtensionAPI;
globalConfigPath?: string | undefined;
initialState: BootstrapConfigState;
manager: ServerManager;
coordinator: ConnectionCoordinator;
bridge: ToolBridge;
resources: ResourceBridge;
}
function hasRegisteredServersApi(
pi: ExtensionAPI,
): pi is ExtensionAPI & { getMcpServers: () => RegisteredMcpServer[] } {
return typeof (pi as { getMcpServers?: () => RegisteredMcpServer[] }).getMcpServers === "function";
}
function reportBootstrapConfigError(err: unknown): void {
const { kind, message } = describeError(err);
reportDiagnostic({
level: "error",
event: "config.error",
message: `Config error: ${message}`,
fields: { kind },
});
}
function notifySessionConfigError(ctx: ExtensionContext, err: unknown): void {
const msg = formatErrorMessage(err);
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
}
export function collectRegisteredServers(
entries: readonly RegisteredMcpServer[],
fileServers: Record<string, ServerConfig>,
onError?: (err: unknown) => void,
): Record<string, ServerConfig> {
const registered: Record<string, ServerConfig> = {};
for (const entry of entries) {
// Servers configured in mcp.json take precedence over extension-registered servers.
if (Object.prototype.hasOwnProperty.call(fileServers, entry.name)) {
continue;
}
try {
registered[entry.name] = parseRegisteredServerConfig(
entry.name,
entry.config,
entry.extensionPath || "<extension>",
);
} catch (err) {
onError?.(err);
}
}
return registered;
}
export function mergeWithRegisteredServers(
fileConfig: McpConfig,
registeredServers: Record<string, ServerConfig>,
): McpConfig {
return {
settings: fileConfig.settings,
mcpServers: {
...registeredServers,
...fileConfig.mcpServers,
},
};
}
/**
* Load only global config and extension-registered servers at bootstrap.
*
* WHY: cwd and project trust are available on the ExtensionContext passed to event handlers.
* At bootstrap (before session_start) we load ONLY the global config so untrusted
* `<cwd>/.pi/mcp.json` files are never read before `ctx.isProjectTrusted()` is checked.
*/
export async function loadBootstrapConfigState(
pi: ExtensionAPI,
paths: BootstrapConfigPaths = {},
): Promise<BootstrapConfigState> {
const bootstrapCwd = paths.bootstrapCwd ?? process.cwd();
let fileConfig: McpConfig = {
settings: { ...DEFAULT_SETTINGS },
mcpServers: {},
};
// Where each configured server came from. The bootstrap load skips the project
// config (trust is unknown before session_start), so this is replaced on the
// first session_start with the provenance of the config actually in effect.
let fileProvenance: ConfigProvenanceInput = {
origins: {},
projectConfigPath: join(bootstrapCwd, ".pi", "mcp.json"),
projectConfigIncluded: false,
};
try {
const loaded = await loadConfigWithOrigins(bootstrapCwd, paths.globalConfigPath, {
includeProjectConfig: false,
});
fileConfig = loaded.config;
fileProvenance = loaded;
} catch (err) {
// Can't notify yet (no ctx), so the diagnostic goes to stderr (and to the
// debug log when it is enabled). The session_start handler will re-try with
// the real cwd and surface errors properly.
reportBootstrapConfigError(err);
}
const initialEntries = hasRegisteredServersApi(pi) ? pi.getMcpServers() : [];
const extensionServers = collectRegisteredServers(
initialEntries,
fileConfig.mcpServers,
reportBootstrapConfigError,
);
const config = mergeWithRegisteredServers(fileConfig, extensionServers);
return {
fileConfig,
fileProvenance,
extensionServers,
config,
};
}
export function isProjectTrusted(ctx: ExtensionContext): boolean {
return typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === "function"
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
: true;
}
export function filterEagerServers(
servers: Record<string, ServerConfig>,
): Array<[string, ServerConfig]> {
return Object.entries(servers).filter(([, cfg]) => cfg.lifecycle === "eager");
}
export function removeFromEagerBatch(
batch: Array<[string, ServerConfig]>,
name: string,
): void {
const index = batch.findIndex(([entryName]) => entryName === name);
if (index !== -1) {
batch.splice(index, 1);
}
}
export function abortSupersededEagerStarts(
inFlightEager: ReadonlyMap<string, ServerConfig>,
registeredEntries: readonly RegisteredMcpServer[],
fileServers: Record<string, ServerConfig>,
abortEagerStart: (name: string) => void,
): void {
if (inFlightEager.size === 0) return;
const nextPreview = collectRegisteredServers(registeredEntries, fileServers);
for (const name of findRemovedOrChangedServers(
Object.fromEntries(inFlightEager),
nextPreview,
)) {
abortEagerStart(name);
}
}
export function findRemovedOrChangedServers(
appliedServers: Record<string, ServerConfig>,
nextServers: Record<string, ServerConfig>,
): string[] {
const removedOrChanged: string[] = [];
for (const [name, prevCfg] of Object.entries(appliedServers)) {
const nextCfg = nextServers[name];
if (!nextCfg || JSON.stringify(prevCfg) !== JSON.stringify(nextCfg)) {
removedOrChanged.push(name);
}
}
return removedOrChanged;
}
export function stageAddedServers(
manager: ServerManager,
nextServers: Record<string, ServerConfig>,
): Array<[string, ServerConfig]> {
const addedEager: Array<[string, ServerConfig]> = [];
for (const [name, nextCfg] of Object.entries(nextServers)) {
if (manager.getServer(name)) continue;
manager.setServer(name, nextCfg);
if (nextCfg.lifecycle === "eager") {
addedEager.push([name, nextCfg]);
}
}
return addedEager;
}
/**
* Coordinates `session_start`, `mcp_servers_change`, and `session_shutdown`.
*
* WHY: Pi's MCP registry emits `mcp_servers_change` synchronously for every register() and
* unregister() (`void this.emit(...)` in the runner, which does not wait for earlier
* listeners), so a burst of registrations runs concurrent reconciles. Each reconcile awaits
* process teardown, so the second one sees a half-shut-down server, finishes first, and the
* first one's resume then resurrects an unregistered server — or deletes a server the second
* one just registered, orphaning its stdio child. One serialized queue whose turns re-read the
* live registry collapses a burst into the state that is current when the turn runs.
*/
export class SessionLifecycle {
private readonly deps: SessionLifecycleDeps;
private readonly hasRegisteredServersSource: boolean;
private fileConfig: McpConfig;
private fileProvenance: ConfigProvenanceInput;
private extensionServers: Record<string, ServerConfig>;
private config: McpConfig;
private latestEventEntries: RegisteredMcpServer[] = [];
private sessionActive = false;
private sessionEpoch = 0;
// Counts every `mcp_servers_change` notification and the latest count already reconciled,
// so a burst of events collapses into one turn and session_start can tell whether a
// registration landed after it snapshotted the registry.
private changeEventCount = 0;
private appliedChangeCount = 0;
private reconcileQueue: Promise<void> = Promise.resolve();
private activeSessionStart: Promise<void> = Promise.resolve();
// Eager servers currently inside a startEagerServers call, so a superseding
// mcp_servers_change can abort and remove extension servers immediately, and a session
// transition can stop still-connecting servers without dropping them from config.
private readonly inFlightEager = new Map<string, ServerConfig>();
private readonly inFlightEagerBatch: Array<[string, ServerConfig]> = [];
private readonly pendingEagerOps: Array<Promise<void>> = [];
constructor(deps: SessionLifecycleDeps) {
this.deps = deps;
// WHY: deciding this once keeps the "can we read the live registry?" answer stable; a
// reconcile turn that falls back to a stale event payload would apply superseded state.
this.hasRegisteredServersSource = hasRegisteredServersApi(deps.pi);
this.fileConfig = deps.initialState.fileConfig;
this.fileProvenance = deps.initialState.fileProvenance;
this.extensionServers = deps.initialState.extensionServers;
this.config = deps.initialState.config;
}
getConfig(): McpConfig {
return this.config;
}
getProvenance(): SessionProvenance {
return withExtensionServers(this.fileProvenance, Object.keys(this.extensionServers));
}
async handleSessionStart(ctx: ExtensionContext): Promise<void> {
// WHY: a queued reconcile or earlier startup belongs to the session that started it;
// marking the session inactive, bumping the epoch, stopping any in-flight eager starts,
// and draining both the previous startup and reconcileQueue ensures earlier work has
// settled before runSessionStart diffs sessionConfig against config.
this.sessionActive = false;
const epoch = ++this.sessionEpoch;
this.stopInFlightEagerStarts();
const previousStart = this.activeSessionStart;
const turn = this.executeSessionStartTurn(ctx, previousStart, epoch);
this.activeSessionStart = turn.catch(() => {
// WHY: keep activeSessionStart resolved so a later session_start or session_shutdown never rejects.
});
await turn;
}
handleServersChange(event: unknown, ctx: ExtensionContext): Promise<void> {
if (Array.isArray((event as { servers?: unknown })?.servers)) {
this.latestEventEntries = (event as { servers: RegisteredMcpServer[] }).servers;
}
this.changeEventCount += 1;
abortSupersededEagerStarts(
this.inFlightEager,
this.getRegisteredEntries(),
this.fileConfig.mcpServers,
(name) => this.abortEagerStart(name),
);
return this.enqueueServerReconcile(ctx);
}
async handleSessionShutdown(ctx: ExtensionContext): Promise<void> {
// WHY: invalidate before tearing down so an in-flight startup or queued reconcile
// observes a dead session and bails, then drain both after teardown so shutdown never
// returns while a turn is still mutating state or spawning children. Teardown runs first
// because it aborts any eager start the in-flight turn is waiting on while keeping
// configured servers in ServerManager in "stopped" state (removeServers: false).
this.sessionActive = false;
this.sessionEpoch += 1;
this.inFlightEager.clear();
this.inFlightEagerBatch.length = 0;
reportDiagnostic({
level: "info",
event: "session.teardown",
message: "pi-mcp session shutting down",
fields: { outcome: "shutdown" },
});
await this.deps.coordinator.teardown(ctx, { removeServers: false });
await this.activeSessionStart;
await this.reconcileQueue;
this.deps.coordinator.setActiveContext(undefined);
setDiagnosticUi(null, false);
// WHY: Flush the queued debug-log writes before the process may exit, so a
// bug report attached right after the session ends is not missing events.
await getDebugLog()?.flush();
}
private getRegisteredEntries(): RegisteredMcpServer[] {
return this.hasRegisteredServersSource
? (this.deps.pi as { getMcpServers: () => RegisteredMcpServer[] }).getMcpServers()
: this.latestEventEntries;
}
private isEpochCurrent(epoch: number): boolean {
return epoch === this.sessionEpoch;
}
private isActiveEpoch(epoch: number): boolean {
return this.sessionActive && epoch === this.sessionEpoch;
}
private hasPendingEpochChanges(epoch: number): boolean {
return epoch === this.sessionEpoch && this.changeEventCount !== this.appliedChangeCount;
}
private setSessionConfigState(
nextFileConfig: McpConfig,
nextProvenance: ConfigProvenanceInput,
nextExtensionServers: Record<string, ServerConfig>,
nextConfig: McpConfig,
): void {
this.fileConfig = nextFileConfig;
this.fileProvenance = nextProvenance;
this.appliedChangeCount = this.changeEventCount;
this.extensionServers = nextExtensionServers;
this.config = nextConfig;
}
private applyServers(next: Record<string, ServerConfig>, snapshotCount: number): void {
this.extensionServers = next;
this.config = mergeWithRegisteredServers(this.fileConfig, next);
this.appliedChangeCount = snapshotCount;
}
private removeAppliedServer(name: string): void {
const { [name]: _removed, ...remaining } = this.extensionServers;
this.extensionServers = remaining;
this.config = mergeWithRegisteredServers(this.fileConfig, remaining);
}
private abortEagerStart(name: string): void {
this.inFlightEager.delete(name);
removeFromEagerBatch(this.inFlightEagerBatch, name);
this.deps.coordinator.cancelServer(name);
this.deps.bridge.removeServer(name);
this.deps.resources.forget(name);
this.removeAppliedServer(name);
this.pendingEagerOps.push(this.deps.manager.removeServer(name));
this.deps.coordinator.syncStatus();
}
private stopInFlightEagerStarts(): void {
this.inFlightEager.clear();
for (const [name] of this.inFlightEagerBatch.splice(0)) {
this.deps.coordinator.cancelServer(name);
if (this.deps.manager.getServer(name)?.state === "starting") {
this.pendingEagerOps.push(this.deps.manager.stopServer(name));
}
}
}
private async runEagerStarts(
eagerServers: Array<[string, ServerConfig]>,
ctx: ExtensionContext,
): Promise<void> {
this.inFlightEagerBatch.splice(0, this.inFlightEagerBatch.length, ...eagerServers);
for (const [name, cfg] of eagerServers) {
if (Object.prototype.hasOwnProperty.call(this.extensionServers, name)) {
this.inFlightEager.set(name, cfg);
}
}
try {
if (this.changeEventCount !== this.appliedChangeCount) {
abortSupersededEagerStarts(
this.inFlightEager,
this.getRegisteredEntries(),
this.fileConfig.mcpServers,
(name) => this.abortEagerStart(name),
);
}
if (this.inFlightEagerBatch.length > 0) {
await this.deps.coordinator.startEagerServers(this.inFlightEagerBatch, ctx);
}
} finally {
this.inFlightEager.clear();
this.inFlightEagerBatch.length = 0;
if (this.pendingEagerOps.length > 0) {
await Promise.allSettled(this.pendingEagerOps.splice(0));
}
}
}
private async executeSessionStartTurn(
ctx: ExtensionContext,
previousStart: Promise<void>,
epoch: number,
): Promise<void> {
await previousStart;
await this.reconcileQueue;
try {
if (this.isEpochCurrent(epoch)) {
await this.runSessionStart(ctx, epoch);
}
} finally {
// WHY: only activate the session if neither session_shutdown nor a newer session_start
// superseded this startup while runSessionStart was awaiting config I/O or eager connects.
if (this.isEpochCurrent(epoch)) {
this.sessionActive = true;
// WHY: reconcile only when a registration landed after runSessionStart snapshotted
// the registry. An unconditional turn would re-collect the registry on every session
// start and repeat the per-entry config-error notification runSessionStart already
// surfaced. Looping drains any registration that arrives mid-reconcile too.
while (this.hasPendingEpochChanges(epoch)) {
await this.enqueueServerReconcile(ctx);
}
}
}
}
private applyFailedConfigFallback(
ctx: ExtensionContext,
includeProjectConfig: boolean,
): Array<[string, ServerConfig]> {
// WHY: A config that failed to parse still supplies no server origins, so the
// provenance must not keep pointing at the previous session's file entries.
const fallbackProvenance: ConfigProvenanceInput = {
origins: {},
projectConfigPath: join(ctx.cwd, ".pi", "mcp.json"),
projectConfigIncluded: includeProjectConfig,
};
const fallbackFileConfig: McpConfig = {
settings: { ...DEFAULT_SETTINGS },
mcpServers: {},
};
const nextExtensionServers = collectRegisteredServers(
this.getRegisteredEntries(),
fallbackFileConfig.mcpServers,
(regErr) => notifySessionConfigError(ctx, regErr),
);
const nextConfig = mergeWithRegisteredServers(fallbackFileConfig, nextExtensionServers);
this.setSessionConfigState(
fallbackFileConfig,
fallbackProvenance,
nextExtensionServers,
nextConfig,
);
this.deps.bridge.updateSettings(nextConfig.settings);
this.deps.manager.rebuildServers(nextConfig);
return Object.keys(nextExtensionServers).length > 0
? filterEagerServers(nextConfig.mcpServers)
: [];
}
private hasSessionConfigChanged(nextLoaded: LoadedConfig): boolean {
const previewExtensionServers = collectRegisteredServers(
this.getRegisteredEntries(),
nextLoaded.config.mcpServers,
);
const previewConfig = mergeWithRegisteredServers(
nextLoaded.config,
previewExtensionServers,
);
return JSON.stringify(previewConfig) !== JSON.stringify(this.config);
}
private commitLoadedConfig(
ctx: ExtensionContext,
nextLoaded: LoadedConfig,
rebuild: boolean,
): Array<[string, ServerConfig]> {
const nextExtensionServers = collectRegisteredServers(
this.getRegisteredEntries(),
nextLoaded.config.mcpServers,
(err) => notifySessionConfigError(ctx, err),
);
const sessionConfig = mergeWithRegisteredServers(nextLoaded.config, nextExtensionServers);
this.setSessionConfigState(
nextLoaded.config,
nextLoaded,
nextExtensionServers,
rebuild ? sessionConfig : this.config,
);
if (rebuild) {
this.deps.bridge.updateSettings(sessionConfig.settings);
this.deps.manager.rebuildServers(sessionConfig);
} else {
// WHY: Even when config is identical across session switches, update running servers'
// cwd and emit notifications/roots/list_changed if the workspace directory changed.
this.deps.manager.updateCwd(ctx.cwd);
this.deps.coordinator.syncStatus(ctx);
}
return filterEagerServers(sessionConfig.mcpServers);
}
private async handleSessionConfigLoadError(
ctx: ExtensionContext,
includeProjectConfig: boolean,
epoch: number,
err: unknown,
): Promise<void> {
if (!this.isEpochCurrent(epoch)) return;
notifySessionConfigError(ctx, err);
// WHY: Switching sessions into a directory with an invalid config must tear down stale
// servers, tools, prompts, and status from the previous session rather than leaving them active.
await this.deps.coordinator.teardown(ctx);
if (!this.isEpochCurrent(epoch)) return;
const eagerServers = this.applyFailedConfigFallback(ctx, includeProjectConfig);
if (!this.isEpochCurrent(epoch) || eagerServers.length === 0) return;
await this.runEagerStarts(eagerServers, ctx);
}
private async applyLoadedSessionConfig(
ctx: ExtensionContext,
nextLoaded: LoadedConfig,
epoch: number,
): Promise<void> {
const configChanged = this.hasSessionConfigChanged(nextLoaded);
if (configChanged) {
reportDiagnostic({
level: "info",
event: "session.reload",
message: "MCP configuration changed; reloading servers and tools",
fields: { outcome: "config_changed" },
});
await this.deps.coordinator.teardown(ctx);
if (!this.isEpochCurrent(epoch)) return;
}
const eagerServers = this.commitLoadedConfig(ctx, nextLoaded, configChanged);
if (!this.isEpochCurrent(epoch) || eagerServers.length === 0) return;
await this.runEagerStarts(eagerServers, ctx);
}
private async runSessionStart(
ctx: ExtensionContext,
epoch: number,
): Promise<void> {
this.deps.coordinator.setActiveContext(ctx);
// WHY: While a UI is active, the extension never writes to Pi's stderr —
// the TUI owns that stream. Diagnostics go to the debug log instead, and
// user-actionable ones are surfaced through ctx.ui.notify(...).
setDiagnosticUi(ctx.ui, ctx.hasUI);
reportDiagnostic({
level: "info",
event: "session.started",
message: `pi-mcp session started in ${ctx.cwd}`,
fields: { outcome: "started" },
});
const includeProjectConfig = isProjectTrusted(ctx);
let nextLoaded: LoadedConfig;
try {
nextLoaded = await loadConfigWithOrigins(ctx.cwd, this.deps.globalConfigPath, {
includeProjectConfig,
});
} catch (err) {
await this.handleSessionConfigLoadError(ctx, includeProjectConfig, epoch, err);
return;
}
if (!this.isEpochCurrent(epoch)) return;
await this.applyLoadedSessionConfig(ctx, nextLoaded, epoch);
}
private handleReconcileError(err: unknown, epoch: number, ctx: ExtensionContext): void {
const { kind, message } = describeError(err);
reportDiagnostic({
level: "error",
event: "servers.change_error",
message: `Failed to apply MCP server registration change: ${message}`,
fields: { kind },
});
if (this.isActiveEpoch(epoch)) {
ctx.ui.notify(`pi-mcp: Failed to apply server registration change — ${message}`, "error");
}
}
private enqueueServerReconcile(ctx: ExtensionContext): Promise<void> {
const epoch = this.sessionEpoch;
// WHY: Pi's registry announces every register()/unregister(), including during extension
// loading (before session_start has checked trust and read <cwd>/.pi/mcp.json) and after
// session_shutdown. Spawning servers then attaches child processes to no session; the
// built-in MCP extension ignores those events the same way (its session signal starts
// aborted). session_start reads the registry itself, so the turn just no-ops instead.
const turn = this.reconcileQueue.then(() => this.reconcileRegisteredServers(ctx, epoch));
// WHY: the emitter is fire-and-forget, so a rejected turn would become an unhandled
// rejection; report it instead and keep the queue usable for later registrations.
this.reconcileQueue = turn.catch((err: unknown) => {
this.handleReconcileError(err, epoch, ctx);
});
return this.reconcileQueue;
}
/**
* Bring the manager, bridge, and config in line with the current registrations.
*
* `isActiveEpoch` and `changeEventCount` are checked at entry and after each removal await, so a
* session switch/shutdown or a newer registration that arrived while a teardown was in flight
* stops the turn before it re-adds or starts superseded servers. An eager start already running
* when a newer registration or session transition arrives is aborted via `runEagerStarts`.
*/
private async reconcileRegisteredServers(
ctx: ExtensionContext,
epoch: number,
): Promise<void> {
const snapshotCount = this.changeEventCount;
if (!this.isActiveEpoch(epoch) || this.appliedChangeCount === snapshotCount) return;
this.deps.coordinator.setActiveContext(ctx);
setDiagnosticUi(ctx.ui, ctx.hasUI);
const configErrors: string[] = [];
const nextExtensionServers = collectRegisteredServers(
this.getRegisteredEntries(),
this.fileConfig.mcpServers,
(err) => {
configErrors.push(formatErrorMessage(err));
},
);
const removedOrChanged = findRemovedOrChangedServers(
this.extensionServers,
nextExtensionServers,
);
for (const name of removedOrChanged) {
this.deps.coordinator.cancelServer(name);
this.deps.bridge.removeServer(name);
this.deps.resources.forget(name);
this.removeAppliedServer(name);
await this.deps.manager.removeServer(name);
this.deps.coordinator.syncStatus(ctx);
// WHY: the next step re-adds and starts servers, which must never happen when the
// session went away or a newer registration superseded this snapshot during teardown.
if (!this.isActiveEpoch(epoch) || this.changeEventCount !== snapshotCount) return;
}
for (const msg of configErrors) {
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
}
const addedEager = stageAddedServers(this.deps.manager, nextExtensionServers);
this.applyServers(nextExtensionServers, snapshotCount);
this.deps.coordinator.syncStatus(ctx);
if (addedEager.length > 0) {
await this.runEagerStarts(addedEager, ctx);
}
}
}