diff --git a/ts/.vscode/settings.json b/ts/.vscode/settings.json index 2ff2419e62..d7a690c1a8 100644 --- a/ts/.vscode/settings.json +++ b/ts/.vscode/settings.json @@ -49,5 +49,14 @@ "jest.jestCommandLine": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", "chat.tools.terminal.autoApprove": { "ForEach-Object": true + }, + "files.watcherExclude": { + "**/node_modules/**": true, + "**/.git/objects/**": true, + "**/dist/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true } } \ No newline at end of file diff --git a/ts/docs/architecture/dispatcher.md b/ts/docs/architecture/dispatcher.md index 57c1cd3eed..ad305e3d66 100644 --- a/ts/docs/architecture/dispatcher.md +++ b/ts/docs/architecture/dispatcher.md @@ -393,6 +393,12 @@ provider.getAppAgentNames() # Discover available agents │ │ - Load flow definitions │ │ │ └─────────────────────────────────────────────┘ │ │ │ +│ If manifest.localView = true: │ +│ - Reserve a port slot (assigned 0 = OS-chosen) │ +│ - Agent's view server spawned on first activation │ +│ - Server binds to OS-assigned port, reports back │ +│ via IPC → stored via SessionContext.setLocalHostPort│ +│ │ └───────────────────────────────────────────────────────┘ ``` diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index 8ded248adc..d8f968c4e0 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -277,6 +277,13 @@ export async function createAgentRpcClient( const context = contextMap.get(param.contextId); return context.getSharedLocalHostPort(param.agentName); }, + setLocalHostPort: async (param: { + contextId: number; + port: number; + }) => { + const context = contextMap.get(param.contextId); + context.setLocalHostPort(param.port); + }, indexes: async (param: { contextId: number; type: string }) => { const context = contextMap.get(param.contextId); return context.indexes(param.type as any); diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index d4e530f7f4..21602d2cc2 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -450,6 +450,11 @@ export function createAgentRpcServer( agentName, }); }, + setLocalHostPort(port: number) { + void rpc + .invoke("setLocalHostPort", { contextId, port }) + .catch(); + }, addDynamicAgent: async ( name: string, manifest: AppAgentManifest, diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index e91bf5be03..a6861efc6b 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -121,6 +121,10 @@ export type AgentContextInvokeFunctions = { contextId: number; agentName: string; }) => Promise; + setLocalHostPort: (param: { + contextId: number; + port: number; + }) => Promise; indexes: (param: { contextId: number; type: string }) => Promise; reloadAgentSchema: (param: { contextId: number }) => Promise; popupQuestion: (param: { diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 17e4490660..c05d4a1036 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -227,6 +227,9 @@ export interface SessionContext { // Experimental: get the shared local host port getSharedLocalHostPort(agentName: string): Promise; + // Experimental: update this agent's bound local host port (used after OS port assignment) + setLocalHostPort(port: number): void; + // Experimental: get the available indexes indexes(type: "image" | "email" | "website" | "all"): Promise; } diff --git a/ts/packages/agents/browser/benchmark/test-webflow-grammar.mts b/ts/packages/agents/browser/benchmark/test-webflow-grammar.mts index f36fad8049..80ecb14279 100644 --- a/ts/packages/agents/browser/benchmark/test-webflow-grammar.mts +++ b/ts/packages/agents/browser/benchmark/test-webflow-grammar.mts @@ -85,7 +85,6 @@ async function main() { agents: { actions: true, commands: true }, execution: { history: false }, collectCommandResult: true, - portBase: 9400, persistDir, storageProvider: getFsStorageProvider(), }); diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index c3d7803e55..6bfbb2c717 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -259,6 +259,7 @@ export function instantiate(): AppAgent { return { initializeAgentContext: initializeBrowserContext, updateAgentContext: updateBrowserContext, + closeAgentContext: closeBrowserContext, executeAction: executeBrowserAction, resolveEntity, getDynamicDisplay: getDynamicDisplayImpl, @@ -540,6 +541,23 @@ async function updateBrowserContext( } } +async function closeBrowserContext( + context: SessionContext, +) { + if (context.agentContext.agentWebSocketServer) { + context.agentContext.agentWebSocketServer.stop(); + delete context.agentContext.agentWebSocketServer; + } + if (context.agentContext.browserProcess) { + context.agentContext.browserProcess.kill(); + context.agentContext.browserProcess = undefined; + } + if (context.agentContext.viewProcess) { + context.agentContext.viewProcess.kill(); + context.agentContext.viewProcess = undefined; + } +} + async function handleWebAgentRpc( method: string, params: any, @@ -1966,8 +1984,10 @@ async function createViewServiceHost( }, }); - childProcess.on("message", function (message) { - if (message === "Success") { + childProcess.on("message", function (message: any) { + if (message?.type === "Success") { + context.agentContext.localHostPort = message.port; + context.setLocalHostPort(message.port); resolve(childProcess); } else if (message === "Failure") { resolve(undefined); diff --git a/ts/packages/agents/browser/src/agent/websiteMemory.mts b/ts/packages/agents/browser/src/agent/websiteMemory.mts index a2386b7bd0..ff288c4f70 100644 --- a/ts/packages/agents/browser/src/agent/websiteMemory.mts +++ b/ts/packages/agents/browser/src/agent/websiteMemory.mts @@ -157,6 +157,7 @@ export async function resolveURLWithHistory( removeDynamicAgent: async () => {}, forceCleanupDynamicAgent: async () => {}, getSharedLocalHostPort: async () => 0, + setLocalHostPort: (_port: number) => {}, indexes: async () => [], reloadAgentSchema: async () => {}, }; diff --git a/ts/packages/agents/browser/src/views/server/core/baseServer.ts b/ts/packages/agents/browser/src/views/server/core/baseServer.ts index b009d9909a..9474391730 100644 --- a/ts/packages/agents/browser/src/views/server/core/baseServer.ts +++ b/ts/packages/agents/browser/src/views/server/core/baseServer.ts @@ -23,6 +23,7 @@ export class BaseServer { private sseManager: SSEManager; private features: Map = new Map(); private config: ServerConfig; + private boundPort: number | undefined; constructor(config: ServerConfig) { this.config = config; @@ -142,18 +143,45 @@ export class BaseServer { return this.app; } + get port(): number { + if (this.boundPort === undefined) { + throw new Error("Server has not been started yet."); + } + return this.boundPort; + } + /** * Start the server */ start(): Promise { - return new Promise((resolve) => { - this.app.listen(this.config.port, () => { - debug(`Server running at http://localhost:${this.config.port}`); + return new Promise((resolve, reject) => { + const server = this.app.listen(this.config.port, () => { + this.boundPort = (server.address() as { port: number }).port; + debug(`Server running at http://localhost:${this.boundPort}`); debug( `Registered features: ${Array.from(this.features.keys()).join(", ")}`, ); + server.removeListener("error", onStartupError); + server.on("error", (err: NodeJS.ErrnoException) => { + console.error( + `Server runtime error on port ${this.boundPort}:`, + err, + ); + }); resolve(); }); + const onStartupError = (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${this.config.port} is already in use. Is another instance already running?`, + ), + ); + } else { + reject(err); + } + }; + server.on("error", onStartupError); }); } } diff --git a/ts/packages/agents/browser/src/views/server/server.mts b/ts/packages/agents/browser/src/views/server/server.mts index f039246f19..754267b52d 100644 --- a/ts/packages/agents/browser/src/views/server/server.mts +++ b/ts/packages/agents/browser/src/views/server/server.mts @@ -34,7 +34,7 @@ async function main() { await server.start(); // Process lifecycle management - process.send?.("Success"); + process.send?.({ type: "Success", port: server.port }); process.on("message", (message: any) => { debug("Received message:", message); diff --git a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts index e4345e8d04..a7f7255d32 100644 --- a/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts +++ b/ts/packages/agents/markdown/src/agent/markdownActionHandler.ts @@ -297,10 +297,15 @@ async function updateMarkdownContext( const fullPath = await getFullMarkdownFilePath(fileName, storage!); if (fullPath) { process.env.MARKDOWN_FILE = fullPath; - context.agentContext.viewProcess = await createViewServiceHost( + const result = await createViewServiceHost( fullPath, context.agentContext.localHostPort, ); + if (result) { + context.agentContext.viewProcess = result.process; + context.agentContext.localHostPort = result.port; + context.setLocalHostPort(result.port); + } } } @@ -878,7 +883,10 @@ async function getDocumentContentFromView( // NOTE: Function commented out per Flow 1 consolidation // Collaboration server now managed by view process -async function createViewServiceHost(filePath: string, port: number) { +async function createViewServiceHost( + filePath: string, + port: number, +): Promise<{ process: ChildProcess; port: number } | undefined> { let timeoutHandle: NodeJS.Timeout; const timeoutPromise = new Promise((_resolve, reject) => { @@ -888,47 +896,47 @@ async function createViewServiceHost(filePath: string, port: number) { }, 10000); }); - const viewServicePromise = new Promise( - (resolve, reject) => { - try { - const expressService = fileURLToPath( - new URL( - path.join("..", "./view/route/service.js"), - import.meta.url, - ), - ); + const viewServicePromise = new Promise< + { process: ChildProcess; port: number } | undefined + >((resolve, reject) => { + try { + const expressService = fileURLToPath( + new URL( + path.join("..", "./view/route/service.js"), + import.meta.url, + ), + ); - const folderPath = path.dirname(filePath!); + const folderPath = path.dirname(filePath!); - const childProcess = fork(expressService, [port.toString()], { - env: { - ...process.env, - TYPEAGENT_MARKDOWN_ROOT: folderPath, - }, - }); - - childProcess.send({ - type: "setFile", - filePath: path.basename(filePath), - }); - - childProcess.on("message", function (message: any) { - if (message === "Success") { - resolve(childProcess); - } else if (message === "Failure") { - resolve(undefined); - } - }); - - childProcess.on("exit", (code) => { - debug("Markdown view server exited with code:", code); - }); - } catch (e: any) { - console.error(e); - resolve(undefined); - } - }, - ); + const childProcess = fork(expressService, [port.toString()], { + env: { + ...process.env, + TYPEAGENT_MARKDOWN_ROOT: folderPath, + }, + }); + + childProcess.send({ + type: "setFile", + filePath: path.basename(filePath), + }); + + childProcess.on("message", function (message: any) { + if (message?.type === "Success") { + resolve({ process: childProcess, port: message.port }); + } else if (message === "Failure") { + resolve(undefined); + } + }); + + childProcess.on("exit", (code) => { + debug("Markdown view server exited with code:", code); + }); + } catch (e: any) { + console.error(e); + resolve(undefined); + } + }); return Promise.race([viewServicePromise, timeoutPromise]).then((result) => { clearTimeout(timeoutHandle); diff --git a/ts/packages/agents/markdown/src/view/route/service.ts b/ts/packages/agents/markdown/src/view/route/service.ts index 5552d0691a..e611c764e0 100644 --- a/ts/packages/agents/markdown/src/view/route/service.ts +++ b/ts/packages/agents/markdown/src/view/route/service.ts @@ -2245,9 +2245,20 @@ debug(`[SIGNAL] Y.js WebSocket server integrated`); // Start the HTTP server (which includes WebSocket support) server.listen(port, () => { - debug(`Express server with WebSocket support listening on port ${port}`); - debug(`Y.js collaboration available at ws://localhost:${port}/`); + const boundPort = (server.address() as { port: number }).port; + debug( + `Express server with WebSocket support listening on port ${boundPort}`, + ); + debug( + `Y.js collaboration available at ws://localhost:${boundPort}/`, + ); // Send success signal to parent process AFTER server is ready to accept WebSocket connections - process.send?.("Success"); + process.send?.({ type: "Success", port: boundPort }); +}); + +server.on("error", (err: NodeJS.ErrnoException) => { + console.error("Markdown view server failed to start:", err); + process.send?.("Failure"); + process.exit(1); }); diff --git a/ts/packages/agents/montage/src/agent/montageActionHandler.ts b/ts/packages/agents/montage/src/agent/montageActionHandler.ts index 9d91eccbd3..fd1c1abb0d 100644 --- a/ts/packages/agents/montage/src/agent/montageActionHandler.ts +++ b/ts/packages/agents/montage/src/agent/montageActionHandler.ts @@ -230,6 +230,10 @@ async function closeMontageContext( context: SessionContext, ) { await saveMontages(context); + if (context.agentContext.viewProcess) { + context.agentContext.viewProcess.kill(); + context.agentContext.viewProcess = undefined; + } } async function updateMontageContext( @@ -277,7 +281,7 @@ async function updateMontageContext( // Start the montage rendering host if (!agentContext.viewProcess) { - agentContext.viewProcess = await createViewServiceHost( + const viewServiceResult = await createViewServiceHost( (montage: PhotoMontage) => { // replace the active montage with the one the client gave is if they match if (agentContext.activeMontageId == montage.id) { @@ -292,6 +296,10 @@ async function updateMontageContext( }, agentContext.localHostPort, ); + if (viewServiceResult) { + agentContext.viewProcess = viewServiceResult.process; + context.setLocalHostPort(viewServiceResult.port); + } // send the folder info if (agentContext.indexes.length !== 0) { @@ -962,41 +970,47 @@ export async function createViewServiceHost( ); }); - const viewServicePromise = new Promise( - (resolve, reject) => { - try { - const expressService = fileURLToPath( - new URL( - path.join("..", "./route/route.js"), - import.meta.url, - ), - ); + const viewServicePromise = new Promise< + { process: ChildProcess; port: number } | undefined + >((resolve, reject) => { + try { + const expressService = fileURLToPath( + new URL(path.join("..", "./route/route.js"), import.meta.url), + ); - const childProcess = fork(expressService, [port.toString()]); - - childProcess.on("message", function (message) { - if (message === "Success") { - resolve(childProcess); - } else if (message === "Failure") { - resolve(undefined); - } else { - const mon: PhotoMontage | undefined = - message as PhotoMontage; - if (mon) { - montageUpdatedCallback(mon); - } + const childProcess = fork(expressService, [port.toString()]); + + childProcess.on("message", function (message) { + if ( + message !== null && + typeof message === "object" && + "success" in (message as object) && + (message as { success: boolean }).success + ) { + resolve({ + process: childProcess, + port: (message as { success: boolean; port: number }) + .port, + }); + } else if (message === "Failure") { + resolve(undefined); + } else { + const mon: PhotoMontage | undefined = + message as PhotoMontage; + if (mon) { + montageUpdatedCallback(mon); } - }); + } + }); - childProcess.on("exit", (code) => { - debug("Montage view server exited with code:", code); - }); - } catch (e: any) { - console.error(e); - resolve(undefined); - } - }, - ); + childProcess.on("exit", (code) => { + debug("Montage view server exited with code:", code); + }); + } catch (e: any) { + console.error(e); + resolve(undefined); + } + }); return Promise.race([viewServicePromise, timeoutPromise]).then((result) => { clearTimeout(timeoutHandle); diff --git a/ts/packages/agents/montage/src/route/route.ts b/ts/packages/agents/montage/src/route/route.ts index e451ff06c5..54d86bb371 100644 --- a/ts/packages/agents/montage/src/route/route.ts +++ b/ts/packages/agents/montage/src/route/route.ts @@ -238,11 +238,6 @@ function sendDataToClients(message: any) { } } -/** - * Indicate to the host/parent process that we've started successfully - */ -process.send?.("Success"); - /** * Processes messages received from the host/parent process */ @@ -278,5 +273,18 @@ process.on("disconnect", () => { }); // Start the server -app.listen(port); -debug(`Montage server started on port ${port}`); +const server = app.listen(port, () => { + const boundPort = (server.address() as { port: number }).port; + debug(`Montage server started on port ${boundPort}`); + process.send?.({ success: true, port: boundPort }); +}); +server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error( + `Port ${port} is already in use. Is another instance already running?`, + ); + } else { + console.error(`Server error: ${err.message}`); + } + process.exitCode = 1; +}); diff --git a/ts/packages/agents/scriptflow/benchmark/run-benchmark.mts b/ts/packages/agents/scriptflow/benchmark/run-benchmark.mts index a2e98605d3..6454de78af 100644 --- a/ts/packages/agents/scriptflow/benchmark/run-benchmark.mts +++ b/ts/packages/agents/scriptflow/benchmark/run-benchmark.mts @@ -135,7 +135,6 @@ async function createLiveDispatcher( agents: { actions: true, commands: true }, execution: { history: false }, collectCommandResult: true, - portBase: 9100, persistDir, storageProvider: getFsStorageProvider(), }); diff --git a/ts/packages/agents/taskflow/benchmark/run-benchmark.mts b/ts/packages/agents/taskflow/benchmark/run-benchmark.mts index 343f61c1fb..ede0cfb9f5 100644 --- a/ts/packages/agents/taskflow/benchmark/run-benchmark.mts +++ b/ts/packages/agents/taskflow/benchmark/run-benchmark.mts @@ -190,7 +190,6 @@ async function createLiveDispatcher( agents: { actions: true, commands: true }, execution: { history: false }, collectCommandResult: true, - portBase: 9200, persistDir, storageProvider: getFsStorageProvider(), clientIO, diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index e91988cabb..d4f88acab9 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -126,10 +126,8 @@ export class AppAgentManager implements ActionConfigProvider { private readyWaiters: Array<() => void> = []; private readonly actionSemanticMap?: ActionSchemaSemanticMap; private readonly actionSchemaFileCache: ActionSchemaFileCache; - private nextPortIndex = 0; public constructor( cacheDir: string | undefined, - private readonly portBase: number, private readonly allowSharedLocalView?: string[], private readonly agentInitOptions?: Record, ) { @@ -170,6 +168,12 @@ export class AppAgentManager implements ActionConfigProvider { return record.port; } + public setLocalHostPort(appAgentName: string, port: number) { + const record = this.getRecord(appAgentName); + record.port = port; + debug(`Port ${port} assigned to ${appAgentName}`); + } + public getSharedLocalHostPort(requester: string, target: string) { const record = this.agents.get(target); @@ -507,12 +511,10 @@ export class AppAgentManager implements ActionConfigProvider { } } - const port = manifest.localView - ? this.portBase + this.nextPortIndex++ - : undefined; + const port = manifest.localView ? 0 : undefined; if (port !== undefined) { - debug(`Port ${port} assigned to ${appAgentName}`); + debug(`Dynamic port (OS-assigned) reserved for ${appAgentName}`); } const record: AppAgentRecord = { diff --git a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts index 5bd6e4034f..258e573de6 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts @@ -238,8 +238,9 @@ async function getAgentCache( * - persistSession: whether to save and restore session state across runs. * * Agent port assignments - for agents that host their own http server: - * - portBase: The base port to use for the agents. Default is 9001. Agents will be assigned ports starting from this value. * - allowSharedLocalView: The list of agent names that can get the ports of all other agent's port. Default is undefined. + * Ports are assigned dynamically by the OS (listen on port 0) to avoid conflicts when multiple sessions start concurrently. + * Each agent's view server reports its bound port back to the dispatcher via IPC, which stores it via setLocalHostPort(). * * Logging options: * - metrics: whether to enable collection of timing metrics. Default is false. @@ -261,7 +262,6 @@ export type DispatcherOptions = DeepPartialUndefined & { // Agent port assignments allowSharedLocalView?: string[]; // agents that can access any shared local views, default to undefined - portBase?: number; // default to 9001 // Indexing service discovery indexingServiceRegistry?: IndexingServiceRegistry; // registry for indexing service discovery @@ -567,10 +567,8 @@ export async function initializeCommandHandlerContext( if (embeddingCacheDir) { ensureDirectory(embeddingCacheDir); } - const portBase = options?.portBase ?? 9001; const agents = new AppAgentManager( cacheDir, - portBase, options?.allowSharedLocalView, options?.agentInitOptions, ); diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index 4f2f3e08c4..699d6cb6be 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -140,6 +140,9 @@ export function createSessionContext( } return localHostPort; }, + setLocalHostPort(port: number) { + context.agents.setLocalHostPort(name, port); + }, indexes(type: string): Promise { return new Promise((resolve, reject) => { const iidx: IndexData[] = diff --git a/ts/packages/shell/src/main/instance.ts b/ts/packages/shell/src/main/instance.ts index 9770c7a6bc..2b6dbd12b2 100644 --- a/ts/packages/shell/src/main/instance.ts +++ b/ts/packages/shell/src/main/instance.ts @@ -32,7 +32,7 @@ import { import { getStatusSummary } from "agent-dispatcher/helpers/status"; import { setPendingUpdateCallback } from "./commands/update.js"; import { createClientIORpcClient } from "@typeagent/dispatcher-rpc/clientio/client"; -import { isProd, isTest } from "./index.js"; +import { isTest } from "./index.js"; import { getFsStorageProvider } from "dispatcher-node-providers"; import { ensureAndConnectDispatcher } from "@typeagent/agent-server-client"; @@ -176,7 +176,6 @@ async function initializeDispatcher( indexingServiceRegistry, constructionProvider: getDefaultConstructionProvider(), allowSharedLocalView: ["browser"], - portBase: isProd ? 9001 : 9050, }); }