diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index effc85ba..1541475d 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.post("/api/workspaces", async (request, reply) => { - const body = WorkspaceCreateSchema.parse(request.body ?? {}) - const workspace = await deps.workspaceManager.create(body.path, body.name) - reply.code(201) - return workspace + try { + const body = WorkspaceCreateSchema.parse(request.body ?? {}) + const workspace = await deps.workspaceManager.create(body.path, body.name) + reply.code(201) + return workspace + } catch (error) { + request.log.error({ err: error }, "Failed to create workspace") + const message = error instanceof Error ? error.message : "Failed to create workspace" + reply.code(400).type("text/plain").send(message) + } }) app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a23c2178..02af0223 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,5 +1,6 @@ import path from "path" import { spawnSync } from "child_process" +import { connect } from "net" import { EventBus } from "../events/bus" import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" @@ -7,10 +8,12 @@ import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" -import { WorkspaceRuntime } from "./runtime" +import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { getOpencodeConfigDir } from "../opencode-config" +const STARTUP_STABILITY_DELAY_MS = 1500 + interface WorkspaceManagerOptions { rootDir: string configStore: ConfigStore @@ -108,7 +111,7 @@ export class WorkspaceManager { } try { - const { pid, port } = await this.runtime.launch({ + const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: resolvedBinaryPath, @@ -116,6 +119,8 @@ export class WorkspaceManager { onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) + await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) + descriptor.pid = pid descriptor.port = port descriptor.status = "ready" @@ -241,6 +246,159 @@ export class WorkspaceManager { return undefined } + private async waitForWorkspaceReadiness(params: { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + }) { + + await Promise.race([ + this.waitForPortAvailability(params.port), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited before becoming ready", + info, + params.getLastOutput(), + ) + }), + ]) + + await this.waitForInstanceHealth(params) + + await Promise.race([ + this.delay(STARTUP_STABILITY_DELAY_MS), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited shortly after start", + info, + params.getLastOutput(), + ) + }), + ]) + } + + private async waitForInstanceHealth(params: { + workspaceId: string + port: number + exitPromise: Promise + getLastOutput: () => string + }) { + const probeResult = await Promise.race([ + this.probeInstance(params.workspaceId, params.port), + params.exitPromise.then((info) => { + throw this.buildStartupError( + params.workspaceId, + "exited during health checks", + info, + params.getLastOutput(), + ) + }), + ]) + + if (probeResult.ok) { + return + } + + const latestOutput = params.getLastOutput().trim() + const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : "" + const reason = probeResult.reason ?? "Health check failed" + throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`) + } + + private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> { + const url = `http://127.0.0.1:${port}/project/current` + + try { + const response = await fetch(url) + if (!response.ok) { + const reason = `health probe returned HTTP ${response.status}` + this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") + return { ok: false, reason } + } + return { ok: true } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") + return { ok: false, reason } + } + } + + private buildStartupError( + workspaceId: string, + phase: string, + exitInfo: ProcessExitInfo, + lastOutput: string, + ): Error { + const exitDetails = this.describeExit(exitInfo) + const trimmedOutput = lastOutput.trim() + const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : "" + return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`) + } + + private waitForPortAvailability(port: number, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs + let settled = false + let retryTimer: NodeJS.Timeout | null = null + + const cleanup = () => { + settled = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + } + + const tryConnect = () => { + if (settled) { + return + } + const socket = connect({ port, host: "127.0.0.1" }, () => { + cleanup() + socket.end() + resolve() + }) + socket.once("error", () => { + socket.destroy() + if (settled) { + return + } + if (Date.now() >= deadline) { + cleanup() + reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`)) + } else { + retryTimer = setTimeout(() => { + retryTimer = null + tryConnect() + }, 100) + } + }) + } + + tryConnect() + }) + } + + private delay(durationMs: number): Promise { + if (durationMs <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => setTimeout(resolve, durationMs)) + } + + private describeExit(info: ProcessExitInfo): string { + if (info.signal) { + return `signal ${info.signal}` + } + if (info.code !== null) { + return `code ${info.code}` + } + return "unknown reason" + } + private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { const workspace = this.workspaces.get(workspaceId) if (!workspace) return diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index b977c115..7c1c5f12 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -13,7 +13,7 @@ interface LaunchOptions { onExit?: (info: ProcessExitInfo) => void } -interface ProcessExitInfo { +export interface ProcessExitInfo { workspaceId: string code: number | null signal: NodeJS.Signals | null @@ -30,12 +30,18 @@ export class WorkspaceRuntime { constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {} - async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> { + async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] const env = { ...process.env, ...(options.environment ?? {}) } + let exitResolve: ((info: ProcessExitInfo) => void) | null = null + const exitPromise = new Promise((resolveExit) => { + exitResolve = resolveExit + }) + let lastOutput = "" + return new Promise((resolve, reject) => { this.logger.info( { workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath }, @@ -83,11 +89,21 @@ export class WorkspaceRuntime { cleanupStreams() child.removeListener("error", handleError) child.removeListener("exit", handleExit) + const exitInfo: ProcessExitInfo = { + workspaceId: options.workspaceId, + code, + signal, + requested: managed.requestedStop, + } + if (exitResolve) { + exitResolve(exitInfo) + exitResolve = null + } if (!portFound) { const reason = stderrBuffer || `Process exited with code ${code}` reject(new Error(reason)) } else { - options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop }) + options.onExit?.(exitInfo) } } @@ -96,6 +112,10 @@ export class WorkspaceRuntime { child.removeListener("exit", handleExit) this.processes.delete(options.workspaceId) this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error") + if (exitResolve) { + exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop }) + exitResolve = null + } reject(error) } @@ -109,18 +129,20 @@ export class WorkspaceRuntime { stdoutBuffer = lines.pop() ?? "" for (const line of lines) { - if (!line.trim()) continue + const trimmed = line.trim() + if (!trimmed) continue + lastOutput = trimmed this.emitLog(options.workspaceId, "info", line) if (!portFound) { const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i) if (portMatch) { portFound = true - cleanupStreams() child.removeListener("error", handleError) const port = parseInt(portMatch[1], 10) this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port") - resolve({ pid: child.pid!, port }) + const getLastOutput = () => lastOutput.trim() + resolve({ pid: child.pid!, port, exitPromise, getLastOutput }) } } } @@ -133,7 +155,9 @@ export class WorkspaceRuntime { stderrBuffer = lines.pop() ?? "" for (const line of lines) { - if (!line.trim()) continue + const trimmed = line.trim() + if (!trimmed) continue + lastOutput = `[stderr] ${trimmed}` this.emitLog(options.workspaceId, "error", line) } }) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index cc1b8762..78488de8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -21,11 +21,9 @@ import { hasInstances, isSelectingFolder, setIsSelectingFolder, - setHasInstances, showFolderSelection, setShowFolderSelection, } from "./stores/ui" -import { instances as instanceStore } from "./stores/instances" import { useConfig } from "./stores/preferences" import { createInstance, @@ -65,7 +63,12 @@ const App: Component = () => { setThinkingBlocksExpansion, } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - const [launchErrorBinary, setLaunchErrorBinary] = createSignal(null) + interface LaunchErrorState { + message: string + binaryPath: string + missingBinary: boolean + } + const [launchError, setLaunchError] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) @@ -105,14 +108,30 @@ const App: Component = () => { }) const launchErrorPath = () => { - const value = launchErrorBinary() + const value = launchError()?.binaryPath if (!value) return "opencode" return value.trim() || "opencode" } - const isMissingBinaryError = (error: unknown): boolean => { - if (!error) return false - const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) + const launchErrorMessage = () => launchError()?.message ?? "" + + const formatLaunchErrorMessage = (error: unknown): string => { + if (!error) { + return "Failed to launch workspace" + } + const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error) + try { + const parsed = JSON.parse(raw) + if (parsed && typeof parsed.error === "string") { + return parsed.error + } + } catch { + // ignore JSON parse errors + } + return raw + } + + const isMissingBinaryMessage = (message: string): boolean => { const normalized = message.toLowerCase() return ( normalized.includes("opencode binary not found") || @@ -123,7 +142,7 @@ const App: Component = () => { ) } - const clearLaunchError = () => setLaunchErrorBinary(null) + const clearLaunchError = () => setLaunchError(null) async function handleSelectFolder(folderPath: string, binaryPath?: string) { if (!folderPath) { @@ -135,7 +154,6 @@ const App: Component = () => { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) - setHasInstances(true) setShowFolderSelection(false) setIsAdvancedSettingsOpen(false) @@ -144,10 +162,13 @@ const App: Component = () => { port: instances().get(instanceId)?.port, }) } catch (error) { - clearLaunchError() - if (isMissingBinaryError(error)) { - setLaunchErrorBinary(selectedBinary) - } + const message = formatLaunchErrorMessage(error) + const missingBinary = isMissingBinaryMessage(message) + setLaunchError({ + message, + binaryPath: selectedBinary, + missingBinary, + }) log.error("Failed to create instance", error) } finally { setIsSelectingFolder(false) @@ -191,9 +212,6 @@ const App: Component = () => { if (!confirmed) return await stopInstance(instanceId) - if (instances().size === 0) { - setHasInstances(false) - } } async function handleNewSession(instanceId: string) { @@ -304,7 +322,7 @@ const App: Component = () => { onClose={handleDisconnectedInstanceClose} /> - +
@@ -312,8 +330,8 @@ const App: Component = () => {
Unable to launch OpenCode - Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from - Advanced Settings. + We couldn't start the selected OpenCode binary. Review the error output below or choose a different + binary from Advanced Settings.
@@ -322,10 +340,23 @@ const App: Component = () => {

{launchErrorPath()}

+ +
+

Error output

+
{launchErrorMessage()}
+
+
+
- + + + diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 0d10adb4..dbce02b6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -34,6 +34,11 @@ const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() + +function syncHasInstancesFlag() { + const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") + setHasInstances(readyExists) +} interface DisconnectedInstanceInfo { id: string folder: string @@ -68,7 +73,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) { updateInstance(descriptor.id, mapped) } else { addInstance(mapped) - setHasInstances(true) } if (descriptor.status === "ready") { @@ -135,9 +139,6 @@ void (async function initializeWorkspaces() { try { const workspaces = await serverApi.fetchWorkspaces() workspaces.forEach((workspace) => upsertWorkspace(workspace)) - if (workspaces.length === 0) { - setHasInstances(false) - } } catch (error) { log.error("Failed to load workspaces", error) } @@ -159,9 +160,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) { case "workspace.stopped": releaseInstanceResources(event.workspaceId) removeInstance(event.workspaceId) - if (instances().size === 0) { - setHasInstances(false) - } break case "workspace.log": handleWorkspaceLog(event.entry) @@ -249,6 +247,7 @@ function addInstance(instance: Instance) { }) ensureLogContainer(instance.id) ensureLogStreamingState(instance.id) + syncHasInstancesFlag() } function updateInstance(id: string, updates: Partial) { @@ -260,6 +259,7 @@ function updateInstance(id: string, updates: Partial) { } return next }) + syncHasInstancesFlag() } function removeInstance(id: string) { @@ -301,6 +301,7 @@ function removeInstance(id: string) { clearCacheForInstance(id) messageStoreBus.unregisterInstance(id) clearInstanceDraftPrompts(id) + syncHasInstancesFlag() } async function createInstance(folder: string, _binaryPath?: string): Promise { @@ -328,9 +329,6 @@ async function stopInstance(id: string) { } removeInstance(id) - if (instances().size === 0) { - setHasInstances(false) - } } async function fetchLspStatus(instanceId: string): Promise { @@ -590,9 +588,6 @@ async function acknowledgeDisconnectedInstance(): Promise { log.error("Failed to stop disconnected instance", error) } finally { setDisconnectedInstance(null) - if (instances().size === 0) { - setHasInstances(false) - } } }