fix: ensure proper resource cleanup and add retry logic for workspace creation

Ayman Bagabas created

Change summary

internal/client/proto.go               |  1 +
internal/cmd/root.go                   | 24 +++++++++++++++++++++++-
internal/config/resolve.go             | 14 ++++++++++++++
internal/proto/mcp.go                  | 15 ++++++++-------
internal/server/config.go              |  2 +-
internal/server/proto.go               |  9 ++++++---
internal/server/server.go              |  2 +-
internal/workspace/client_workspace.go |  8 ++++----
8 files changed, 58 insertions(+), 17 deletions(-)

Detailed changes

internal/client/proto.go 🔗

@@ -82,6 +82,7 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er
 	}
 
 	if rsp.StatusCode != http.StatusOK {
+		rsp.Body.Close()
 		return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
 	}
 

internal/cmd/root.go 🔗

@@ -281,7 +281,29 @@ func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *prot
 		Env:     os.Environ(),
 	})
 	if err != nil {
-		return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
+		// The server socket may exist before the HTTP handler is ready.
+		// Retry a few times with a short backoff.
+		for range 5 {
+			select {
+			case <-ctx.Done():
+				return nil, nil, ctx.Err()
+			case <-time.After(200 * time.Millisecond):
+			}
+			ws, err = c.CreateWorkspace(ctx, proto.Workspace{
+				Path:    cwd,
+				DataDir: dataDir,
+				Debug:   debug,
+				YOLO:    yolo,
+				Version: version.Version,
+				Env:     os.Environ(),
+			})
+			if err == nil {
+				break
+			}
+		}
+		if err != nil {
+			return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
+		}
 	}
 
 	if shouldEnableMetrics(ws.Config) {

internal/config/resolve.go 🔗

@@ -14,6 +14,20 @@ type VariableResolver interface {
 	ResolveValue(value string) (string, error)
 }
 
+// identityResolver is a no-op resolver that returns values unchanged.
+// Used in client mode where variable resolution is handled server-side.
+type identityResolver struct{}
+
+func (identityResolver) ResolveValue(value string) (string, error) {
+	return value, nil
+}
+
+// IdentityResolver returns a VariableResolver that passes values through
+// unchanged.
+func IdentityResolver() VariableResolver {
+	return identityResolver{}
+}
+
 type Shell interface {
 	Exec(ctx context.Context, command string) (stdout, stderr string, err error)
 }

internal/proto/mcp.go 🔗

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"time"
 )
 
 // MCPState represents the current state of an MCP client.
@@ -125,13 +126,13 @@ func (e *MCPEvent) UnmarshalJSON(data []byte) error {
 // MCPClientInfo is the wire-format representation of an MCP client's
 // state, suitable for JSON transport between server and client.
 type MCPClientInfo struct {
-	Name          string   `json:"name"`
-	State         MCPState `json:"state"`
-	Error         error    `json:"error,omitempty"`
-	ToolCount     int      `json:"tool_count,omitempty"`
-	PromptCount   int      `json:"prompt_count,omitempty"`
-	ResourceCount int      `json:"resource_count,omitempty"`
-	ConnectedAt   int64    `json:"connected_at,omitempty"`
+	Name          string    `json:"name"`
+	State         MCPState  `json:"state"`
+	Error         error     `json:"error,omitempty"`
+	ToolCount     int       `json:"tool_count,omitempty"`
+	PromptCount   int       `json:"prompt_count,omitempty"`
+	ResourceCount int       `json:"resource_count,omitempty"`
+	ConnectedAt   time.Time `json:"connected_at"`
 }
 
 // MarshalJSON implements the [json.Marshaler] interface.

internal/server/config.go 🔗

@@ -253,7 +253,7 @@ func (c *controllerV1) handleGetWorkspaceMCPStates(w http.ResponseWriter, r *htt
 			ToolCount:     v.Counts.Tools,
 			PromptCount:   v.Counts.Prompts,
 			ResourceCount: v.Counts.Resources,
-			ConnectedAt:   v.ConnectedAt.Unix(),
+			ConnectedAt:   v.ConnectedAt,
 		}
 	}
 	jsonEncode(w, result)

internal/server/proto.go 🔗

@@ -366,8 +366,6 @@ func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Re
 func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
 	id := r.PathValue("id")
 
-	w.Header().Set("Accept", "application/json")
-
 	var msg proto.AgentMessage
 	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
 		c.server.logError(r, "Failed to decode request", "error", err)
@@ -379,6 +377,7 @@ func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.R
 		c.handleError(w, r, err)
 		return
 	}
+	w.WriteHeader(http.StatusOK)
 }
 
 func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
@@ -387,6 +386,7 @@ func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *ht
 		c.handleError(w, r, err)
 		return
 	}
+	w.WriteHeader(http.StatusOK)
 }
 
 func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
@@ -395,6 +395,7 @@ func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *
 		c.handleError(w, r, err)
 		return
 	}
+	w.WriteHeader(http.StatusOK)
 }
 
 func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
@@ -439,13 +440,14 @@ func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.Respons
 	w.WriteHeader(http.StatusOK)
 }
 
-func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
+func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
 	id := r.PathValue("id")
 	sid := r.PathValue("sid")
 	if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
 		c.handleError(w, r, err)
 		return
 	}
+	w.WriteHeader(http.StatusOK)
 }
 
 func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
@@ -484,6 +486,7 @@ func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter
 		c.handleError(w, r, err)
 		return
 	}
+	w.WriteHeader(http.StatusOK)
 }
 
 func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {

internal/server/server.go 🔗

@@ -143,7 +143,7 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
 	mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
 	mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
 	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
-	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handleGetWorkspaceAgentSessionSummarize)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
 	mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
 	mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
 	mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)

internal/workspace/client_workspace.go 🔗

@@ -350,8 +350,7 @@ func (w *ClientWorkspace) WorkingDir() string {
 }
 
 func (w *ClientWorkspace) Resolver() config.VariableResolver {
-	// In client mode, variable resolution is handled server-side.
-	return nil
+	return config.IdentityResolver()
 }
 
 // -- Config mutations --
@@ -447,7 +446,7 @@ func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
 				Prompts:   v.PromptCount,
 				Resources: v.ResourceCount,
 			},
-			ConnectedAt: time.Unix(v.ConnectedAt, 0),
+			ConnectedAt: v.ConnectedAt,
 		}
 	}
 	return result
@@ -590,7 +589,8 @@ func translateEvent(ev any) tea.Msg {
 			},
 		}
 	default:
-		return ev.(tea.Msg)
+		slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
+		return nil
 	}
 }