From 860180d0fb4e3a9c4b7577846c75fdf707010e28 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Sat, 17 Jan 2026 00:01:18 +0200 Subject: [PATCH 1/4] fix: log should be on info Workspace and/or agent status change should be printed on INFO level, because it is critical information that can help us debug transient changes that can disrupt the ssh connection. --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index d341fa12..5a38ea81 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -279,7 +279,7 @@ class CoderRemoteEnvironment( state.update { environmentStatus.toRemoteEnvironmentState(context) } - context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") + context.logger.info("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}") } /** From 5809d44ecb632505d12abddb9dfbf82e8ad6faa4 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Jan 2026 00:35:44 +0200 Subject: [PATCH 2/4] refactor the workspace and agent status convert from enum to sealed class in order to be able to store the workspace parameter which will be used later to decide whether a workspace is reachable via ssh. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 6 +- .../toolbox/models/WorkspaceAndAgentStatus.kt | 156 ++++++++++-------- 2 files changed, 92 insertions(+), 70 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 5a38ea81..092774ae 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -133,7 +133,7 @@ class CoderRemoteEnvironment( // cli takes 15 seconds to move the workspace in queueing/starting state // while the user won't see anything happening in TBX after start is clicked // During those 15 seconds we work around by forcing a `Queuing` state - updateStatus(WorkspaceAndAgentStatus.QUEUED) + updateStatus(WorkspaceAndAgentStatus.Queued(workspace)) // force refresh of the actions list (Start should no longer be available) refreshAvailableActions() }) @@ -323,14 +323,14 @@ class CoderRemoteEnvironment( // mark the env as deleting otherwise we will have to // wait for the poller to update the status in the next 5 seconds state.update { - WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context) + WorkspaceAndAgentStatus.Deleting(workspace).toRemoteEnvironmentState(context) } context.cs.launch(CoroutineName("Workspace Deletion Poller")) { withTimeout(5.minutes) { var workspaceStillExists = true while (context.cs.isActive && workspaceStillExists) { - if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) { + if (environmentStatus is WorkspaceAndAgentStatus.Deleting || environmentStatus is WorkspaceAndAgentStatus.Deleted) { workspaceStillExists = false context.envPageManager.showPluginEnvironmentsPage() } else { diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 1f48a10e..39f0069d 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -18,41 +18,60 @@ private val CircularSpinner: EnvironmentStateIcons = EnvironmentStateIcons.Conne * WorkspaceAndAgentStatus represents the combined status of a single agent and * its workspace (or just the workspace if there are no agents). */ -enum class WorkspaceAndAgentStatus(val label: String, val description: String) { +sealed class WorkspaceAndAgentStatus( + val label: String, + val workspace: Workspace +) { // Workspace states. - QUEUED("Queued", "The workspace is queueing to start."), - STARTING("Starting", "The workspace is starting."), - FAILED("Failed", "The workspace has failed to start."), - DELETING("Deleting", "The workspace is being deleted."), - DELETED("Deleted", "The workspace has been deleted."), - STOPPING("Stopping", "The workspace is stopping."), - STOPPED("Stopped", "The workspace has stopped."), - CANCELING("Canceling action", "The workspace is being canceled."), - CANCELED("Canceled action", "The workspace has been canceled."), - RUNNING("Running", "The workspace is running, waiting for agents."), + class Queued(workspace: Workspace) : WorkspaceAndAgentStatus("Queued", workspace) + + class Starting(workspace: Workspace) : WorkspaceAndAgentStatus("Starting", workspace) + + class Failed(workspace: Workspace) : WorkspaceAndAgentStatus("Failed", workspace) + + class Deleting(workspace: Workspace) : WorkspaceAndAgentStatus("Deleting", workspace) + + class Deleted(workspace: Workspace) : + WorkspaceAndAgentStatus("Deleted", workspace) + + class Stopping(workspace: Workspace) : WorkspaceAndAgentStatus("Stopping", workspace) + + class Stopped(workspace: Workspace) : WorkspaceAndAgentStatus("Stopped", workspace) + + class Canceling(workspace: Workspace) : WorkspaceAndAgentStatus("Canceling action", workspace) + + class Canceled(workspace: Workspace) : WorkspaceAndAgentStatus("Canceled action", workspace) + + class Running(workspace: Workspace) : WorkspaceAndAgentStatus("Running", workspace) // Agent states. - CONNECTING("Connecting", "The agent is connecting."), - DISCONNECTED("Disconnected", "The agent has disconnected."), - TIMEOUT("Timeout", "The agent is taking longer than expected to connect."), - AGENT_STARTING("Starting", "The startup script is running."), - AGENT_STARTING_READY( - "Starting", - "The startup script is still running but the agent is ready to accept connections.", - ), - CREATED("Created", "The agent has been created."), - START_ERROR("Started with error", "The agent is ready but the startup script errored."), - START_TIMEOUT("Starting", "The startup script is taking longer than expected."), - START_TIMEOUT_READY( - "Starting", - "The startup script is taking longer than expected but the agent is ready to accept connections.", - ), - SHUTTING_DOWN("Shutting down", "The agent is shutting down."), - SHUTDOWN_ERROR("Shutdown with error", "The agent shut down but the shutdown script errored."), - SHUTDOWN_TIMEOUT("Shutting down", "The shutdown script is taking longer than expected."), - OFF("Off", "The agent has shut down."), - READY("Ready", "The agent is ready to accept connections."), - ; + class Connecting(workspace: Workspace) : WorkspaceAndAgentStatus("Connecting", workspace) + + class Disconnected(workspace: Workspace) : WorkspaceAndAgentStatus("Disconnected", workspace) + + class Timeout(workspace: Workspace) : WorkspaceAndAgentStatus("Timeout", workspace) + + class AgentStarting(workspace: Workspace) : WorkspaceAndAgentStatus("Starting", workspace) + + class AgentStartingReady(workspace: Workspace) : WorkspaceAndAgentStatus("Starting", workspace) + + class Created(workspace: Workspace) : WorkspaceAndAgentStatus("Created", workspace) + + class StartError(workspace: Workspace) : WorkspaceAndAgentStatus("Started with error", workspace) + + class StartTimeout(workspace: Workspace) : WorkspaceAndAgentStatus("Starting", workspace) + + class StartTimeoutReady(workspace: Workspace) : WorkspaceAndAgentStatus("Starting", workspace) + + class ShuttingDown(workspace: Workspace) : WorkspaceAndAgentStatus("Shutting down", workspace) + + class ShutdownError(workspace: Workspace) : WorkspaceAndAgentStatus("Shutdown with error", workspace) + + class ShutdownTimeout(workspace: Workspace) : WorkspaceAndAgentStatus("Shutting down", workspace) + + class Off(workspace: Workspace) : WorkspaceAndAgentStatus("Off", workspace) + + class Ready(workspace: Workspace) : WorkspaceAndAgentStatus("Ready", workspace) /** * Return the environment state for Toolbox, which tells it the label, color @@ -73,19 +92,19 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { } private fun getStateColor(context: CoderToolboxContext): StateColor { - return if (this == FAILED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.FailedToStart) - else if (this == DELETING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) - else if (this == DELETED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) + return if (this is Failed) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.FailedToStart) + else if (this is Deleting) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) + else if (this is Deleted) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) else if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) - else if (canStart() || this == STOPPING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Hibernating) + else if (canStart() || this is Stopping) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Hibernating) else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) else context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) } private fun getStateIcon(): EnvironmentStateIcons { - return if (this == FAILED) EnvironmentStateIcons.Error - else if (pending() || this == DELETING || this == DELETED || this == STOPPING) CircularSpinner + return if (this is Failed) EnvironmentStateIcons.Error + else if (pending() || this is Deleting || this is Deleted || this is Stopping) CircularSpinner else if (ready() || unhealthy()) EnvironmentStateIcons.Active else if (canStart()) EnvironmentStateIcons.Offline else EnvironmentStateIcons.NoIcon @@ -94,11 +113,10 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { /** * Return true if the agent is in a connectable state. */ - fun ready(): Boolean = this == READY + fun ready(): Boolean = this is Ready fun unhealthy(): Boolean { - return listOf(START_ERROR, START_TIMEOUT_READY) - .contains(this) + return this is StartError || this is StartTimeoutReady } /** @@ -106,15 +124,13 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { */ fun pending(): Boolean { // See ready() for why `CREATED` is not in this list. - return listOf(CREATED, CONNECTING, TIMEOUT, AGENT_STARTING, START_TIMEOUT, QUEUED, STARTING) - .contains(this) + return this is Created || this is Connecting || this is Timeout || this is AgentStarting || this is StartTimeout || this is Queued || this is Starting } /** * Return true if the workspace can be started. */ - fun canStart(): Boolean = listOf(STOPPED, FAILED, CANCELED) - .contains(this) + fun canStart(): Boolean = this is Stopped || this is Failed || this is Canceled /** * Return true if the workspace can be stopped. @@ -140,36 +156,42 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { workspace: Workspace, agent: WorkspaceAgent? = null, ) = when (workspace.latestBuild.status) { - WorkspaceStatus.PENDING -> QUEUED - WorkspaceStatus.STARTING -> STARTING + WorkspaceStatus.PENDING -> Queued(workspace) + WorkspaceStatus.STARTING -> Starting(workspace) WorkspaceStatus.RUNNING -> when (agent?.status) { WorkspaceAgentStatus.CONNECTED -> when (agent.lifecycleState) { - WorkspaceAgentLifecycleState.CREATED -> CREATED - WorkspaceAgentLifecycleState.STARTING -> if (agent.loginBeforeReady == true) AGENT_STARTING_READY else AGENT_STARTING - WorkspaceAgentLifecycleState.START_TIMEOUT -> if (agent.loginBeforeReady == true) START_TIMEOUT_READY else START_TIMEOUT - WorkspaceAgentLifecycleState.START_ERROR -> START_ERROR - WorkspaceAgentLifecycleState.READY -> READY - WorkspaceAgentLifecycleState.SHUTTING_DOWN -> SHUTTING_DOWN - WorkspaceAgentLifecycleState.SHUTDOWN_TIMEOUT -> SHUTDOWN_TIMEOUT - WorkspaceAgentLifecycleState.SHUTDOWN_ERROR -> SHUTDOWN_ERROR - WorkspaceAgentLifecycleState.OFF -> OFF + WorkspaceAgentLifecycleState.CREATED -> Created(workspace) + WorkspaceAgentLifecycleState.STARTING -> if (agent.loginBeforeReady == true) AgentStartingReady( + workspace + ) else AgentStarting(workspace) + + WorkspaceAgentLifecycleState.START_TIMEOUT -> if (agent.loginBeforeReady == true) StartTimeoutReady( + workspace + ) else StartTimeout(workspace) + + WorkspaceAgentLifecycleState.START_ERROR -> StartError(workspace) + WorkspaceAgentLifecycleState.READY -> Ready(workspace) + WorkspaceAgentLifecycleState.SHUTTING_DOWN -> ShuttingDown(workspace) + WorkspaceAgentLifecycleState.SHUTDOWN_TIMEOUT -> ShutdownTimeout(workspace) + WorkspaceAgentLifecycleState.SHUTDOWN_ERROR -> ShutdownError(workspace) + WorkspaceAgentLifecycleState.OFF -> Off(workspace) } - WorkspaceAgentStatus.DISCONNECTED -> DISCONNECTED - WorkspaceAgentStatus.TIMEOUT -> TIMEOUT - WorkspaceAgentStatus.CONNECTING -> CONNECTING - else -> RUNNING + WorkspaceAgentStatus.DISCONNECTED -> Disconnected(workspace) + WorkspaceAgentStatus.TIMEOUT -> Timeout(workspace) + WorkspaceAgentStatus.CONNECTING -> Connecting(workspace) + else -> Running(workspace) } - WorkspaceStatus.STOPPING -> STOPPING - WorkspaceStatus.STOPPED -> STOPPED - WorkspaceStatus.FAILED -> FAILED - WorkspaceStatus.CANCELING -> CANCELING - WorkspaceStatus.CANCELED -> CANCELED - WorkspaceStatus.DELETING -> DELETING - WorkspaceStatus.DELETED -> DELETED + WorkspaceStatus.STOPPING -> Stopping(workspace) + WorkspaceStatus.STOPPED -> Stopped(workspace) + WorkspaceStatus.FAILED -> Failed(workspace) + WorkspaceStatus.CANCELING -> Canceling(workspace) + WorkspaceStatus.CANCELED -> Canceled(workspace) + WorkspaceStatus.DELETING -> Deleting(workspace) + WorkspaceStatus.DELETED -> Deleted(workspace) } } } From 2cb6ff232fa5e263b329dd09feab5312291a71d7 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Jan 2026 00:52:17 +0200 Subject: [PATCH 3/4] fix: SSH connection is terminated when agent is marked as disconnected/timed out For a bit of context Coder Toolbox can establish an SSH connection when the workspace is in RUNNING state and agent is in READY state. If the connection is already established but for some reason the agent does not respond to pings, then the connectio is terminated by signaling Toolbox that the workspace changed the overall status to a non-reachable state. In reality the connection can still work. After some code research and testing it looks like coder ssh waits for the agent to be connecting before establishing the ssh connection. This allows us to mark the workspace as reachable as soon as it hits the RUNNING state regardless of the agent status. - resolves #246 --- CHANGELOG.md | 4 ++++ .../com/coder/toolbox/models/WorkspaceAndAgentStatus.kt | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27cc7608..ea251ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- improved SSH connection reliability during transient network failures + ## 0.8.3 - 2026-01-14 ### Fixed diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 39f0069d..8a17543f 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -82,9 +82,9 @@ sealed class WorkspaceAndAgentStatus( */ fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { return CustomRemoteEnvironmentStateV2( - context.i18n.pnotr(label), + label = context.i18n.pnotr(label), color = getStateColor(context), - isReachable = ready() || unhealthy(), + isReachable = this.workspace.latestBuild.status == WorkspaceStatus.RUNNING, // TODO@JB: How does this work? Would like a spinner for pending states. iconId = getStateIcon().id, isPriorityShow = true From e6cdcec6c905fee2d60476a6c6c672d313e6aafc Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Jan 2026 19:09:37 +0200 Subject: [PATCH 4/4] chore: next version is 0.8.4 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 743f5333..c540ca11 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.8.3 +version=0.8.4 group=com.coder.toolbox name=coder-toolbox \ No newline at end of file