diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index d53454cd1b..e7f7598ecf 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -35,10 +35,11 @@ func NewShellHandlers(lifecycleMgr *lifecycle.Manager, scriptService scripts.Scr } func (h *ShellHandlers) resolveScopeID(ctx context.Context, sessionID string) (string, error) { - if _, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID); err != nil { - return "", fmt.Errorf("failed to ensure execution for scope: %w", err) + envID, err := h.lifecycleMgr.ResolveTaskEnvironmentID(ctx, sessionID) + if err != nil { + return "", fmt.Errorf("failed to resolve task environment for shell scope: %w", err) } - return h.lifecycleMgr.ResolveScopeKey(sessionID), nil + return envID, nil } // RegisterHandlers registers shell handlers with the WebSocket dispatcher diff --git a/apps/backend/internal/agent/lifecycle/execution_store.go b/apps/backend/internal/agent/lifecycle/execution_store.go index e1c0377510..600b30a76d 100644 --- a/apps/backend/internal/agent/lifecycle/execution_store.go +++ b/apps/backend/internal/agent/lifecycle/execution_store.go @@ -97,6 +97,19 @@ func (s *ExecutionStore) GetBySessionID(sessionID string) (*AgentExecution, bool return execution, exists } +// GetByTaskEnvironmentID returns any execution associated with a task environment ID. +func (s *ExecutionStore) GetByTaskEnvironmentID(taskEnvironmentID string) (*AgentExecution, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, execution := range s.executions { + if execution.TaskEnvironmentID == taskEnvironmentID { + return execution, true + } + } + return nil, false +} + // GetByContainerID returns the agent execution associated with a container ID. func (s *ExecutionStore) GetByContainerID(containerID string) (*AgentExecution, bool) { s.mu.RLock() diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index fd43f63a79..96f5cf6d78 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -49,6 +49,52 @@ func (m *Manager) GetOrEnsureExecution(ctx context.Context, sessionID string) (* return v.(*AgentExecution), nil } +// GetOrEnsureExecutionForEnvironment returns an execution for a task environment, +// creating one on-demand from the workspace info provider when needed. +func (m *Manager) GetOrEnsureExecutionForEnvironment(ctx context.Context, taskEnvironmentID string) (*AgentExecution, error) { + if taskEnvironmentID == "" { + return nil, fmt.Errorf("task_environment_id is required") + } + + if execution, exists := m.executionStore.GetByTaskEnvironmentID(taskEnvironmentID); exists { + return execution, nil + } + + groupKey := "env:" + taskEnvironmentID + v, err, _ := m.ensureExecutionGroup.Do(groupKey, func() (interface{}, error) { + if execution, exists := m.executionStore.GetByTaskEnvironmentID(taskEnvironmentID); exists { + return execution, nil + } + if m.workspaceInfoProvider == nil { + return nil, fmt.Errorf("workspace info provider not configured") + } + info, err := m.workspaceInfoProvider.GetWorkspaceInfoForEnvironment(ctx, taskEnvironmentID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace info for environment %s: %w", taskEnvironmentID, err) + } + if info == nil { + return nil, fmt.Errorf("task environment %s not found", taskEnvironmentID) + } + if info.TaskEnvironmentID == "" { + return nil, fmt.Errorf("task environment %s has no task_environment_id", taskEnvironmentID) + } + if info.TaskEnvironmentID != taskEnvironmentID { + return nil, fmt.Errorf("workspace info resolved environment %s, want %s", info.TaskEnvironmentID, taskEnvironmentID) + } + if info.WorkspacePath == "" { + return nil, fmt.Errorf("%w: task environment %s has no workspace path yet", ErrSessionWorkspaceNotReady, taskEnvironmentID) + } + if info.SessionID == "" { + return nil, fmt.Errorf("task environment %s has no task session", taskEnvironmentID) + } + return m.createExecution(ctx, info.TaskID, info) + }) + if err != nil { + return nil, err + } + return v.(*AgentExecution), nil +} + // EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session. // This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]). // If an execution already exists for the session, it returns it. Otherwise, it creates a new execution diff --git a/apps/backend/internal/agent/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/lifecycle/manager_execution_test.go index 6318b3c97c..6e0a73a032 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution_test.go @@ -4,11 +4,19 @@ import ( "context" "errors" "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" "sync" "sync/atomic" "testing" "time" + "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/common/logger" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -38,7 +46,7 @@ func TestErrSessionWorkspaceNotReady_UnrelatedError(t *testing.T) { } } -func TestResolveScopeKey(t *testing.T) { +func TestResolveTaskEnvironmentID(t *testing.T) { t.Run("returns TaskEnvironmentID when execution carries it", func(t *testing.T) { store := NewExecutionStore() store.Add(&AgentExecution{ @@ -50,20 +58,46 @@ func TestResolveScopeKey(t *testing.T) { }) mgr := &Manager{executionStore: store, logger: newTestLogger()} - if got := mgr.ResolveScopeKey("session-A"); got != "env-1" { - t.Errorf("ResolveScopeKey = %q, want %q", got, "env-1") + got, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-A") + if err != nil { + t.Fatalf("ResolveTaskEnvironmentID returned error: %v", err) + } + if got != "env-1" { + t.Errorf("ResolveTaskEnvironmentID = %q, want %q", got, "env-1") } }) - t.Run("falls back to sessionID when no execution", func(t *testing.T) { + t.Run("returns TaskEnvironmentID from provider when no execution", func(t *testing.T) { + provider := &mockWorkspaceInfoProvider{ + infos: map[string]*WorkspaceInfo{ + "session-X": {SessionID: "session-X", TaskEnvironmentID: "env-X"}, + }, + } + mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger()} + mgr.workspaceInfoProvider = provider + + got, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-X") + if err != nil { + t.Fatalf("ResolveTaskEnvironmentID returned error: %v", err) + } + if got != "env-X" { + t.Errorf("ResolveTaskEnvironmentID = %q, want %q", got, "env-X") + } + }) + + t.Run("errors when no execution and no provider", func(t *testing.T) { mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger()} - if got := mgr.ResolveScopeKey("session-X"); got != "session-X" { - t.Errorf("ResolveScopeKey fallback = %q, want %q", got, "session-X") + _, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-X") + if err == nil { + t.Fatal("expected error") + } + if !containsString(err.Error(), "workspace info provider not configured") { + t.Errorf("unexpected error: %v", err) } }) - t.Run("falls back to sessionID when execution has empty env", func(t *testing.T) { + t.Run("errors when execution has empty env", func(t *testing.T) { store := NewExecutionStore() store.Add(&AgentExecution{ ID: "exec-2", @@ -73,8 +107,33 @@ func TestResolveScopeKey(t *testing.T) { }) mgr := &Manager{executionStore: store, logger: newTestLogger()} - if got := mgr.ResolveScopeKey("session-B"); got != "session-B" { - t.Errorf("ResolveScopeKey legacy = %q, want %q", got, "session-B") + _, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-B") + if err == nil { + t.Fatal("expected error") + } + if !containsString(err.Error(), "no task environment ID") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("errors when provider returns empty env", func(t *testing.T) { + provider := &mockWorkspaceInfoProvider{ + infos: map[string]*WorkspaceInfo{ + "session-C": {SessionID: "session-C"}, + }, + } + mgr := &Manager{ + executionStore: NewExecutionStore(), + workspaceInfoProvider: provider, + logger: newTestLogger(), + } + + _, err := mgr.ResolveTaskEnvironmentID(context.Background(), "session-C") + if err == nil { + t.Fatal("expected error") + } + if !containsString(err.Error(), "no task environment ID") { + t.Errorf("unexpected error: %v", err) } }) @@ -90,7 +149,15 @@ func TestResolveScopeKey(t *testing.T) { }) mgr := &Manager{executionStore: store, logger: newTestLogger()} - if mgr.ResolveScopeKey("sess-A") != mgr.ResolveScopeKey("sess-B") { + envA, err := mgr.ResolveTaskEnvironmentID(context.Background(), "sess-A") + if err != nil { + t.Fatalf("ResolveTaskEnvironmentID(sess-A): %v", err) + } + envB, err := mgr.ResolveTaskEnvironmentID(context.Background(), "sess-B") + if err != nil { + t.Fatalf("ResolveTaskEnvironmentID(sess-B): %v", err) + } + if envA != envB { t.Error("sessions in the same env must resolve to the same scope key") } }) @@ -223,6 +290,191 @@ func TestGetOrEnsureExecution(t *testing.T) { }) } +func TestGetOrEnsureExecutionForEnvironment(t *testing.T) { + t.Run("returns existing execution by environment", func(t *testing.T) { + store := NewExecutionStore() + execution := &AgentExecution{ + ID: "exec-1", + SessionID: "session-1", + TaskID: "task-1", + TaskEnvironmentID: "env-1", + Status: v1.AgentStatusRunning, + } + store.Add(execution) + mgr := &Manager{executionStore: store, logger: newTestLogger()} + + got, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-1") + if err != nil { + t.Fatalf("GetOrEnsureExecutionForEnvironment returned error: %v", err) + } + if got.ID != "exec-1" { + t.Errorf("execution ID = %q, want exec-1", got.ID) + } + }) + + t.Run("creates execution from provider and caches it", func(t *testing.T) { + mgr, backend := newEnvironmentExecutionTestManager(t, &mockWorkspaceInfoProvider{ + envInfos: map[string]*WorkspaceInfo{ + "env-new": { + TaskID: "task-1", + SessionID: "session-1", + TaskEnvironmentID: "env-new", + WorkspacePath: "/workspace/task-1", + AgentID: "auggie", + }, + }, + }) + + got, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-new") + if err != nil { + t.Fatalf("GetOrEnsureExecutionForEnvironment returned error: %v", err) + } + if got.TaskEnvironmentID != "env-new" { + t.Errorf("TaskEnvironmentID = %q, want env-new", got.TaskEnvironmentID) + } + if got.WorkspacePath != "/workspace/task-1" { + t.Errorf("WorkspacePath = %q, want /workspace/task-1", got.WorkspacePath) + } + + got2, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-new") + if err != nil { + t.Fatalf("second GetOrEnsureExecutionForEnvironment returned error: %v", err) + } + if got2.ID != got.ID { + t.Errorf("cached execution ID = %q, want %q", got2.ID, got.ID) + } + if backend.createCount.Load() != 1 { + t.Errorf("CreateInstance calls = %d, want 1", backend.createCount.Load()) + } + }) + + t.Run("concurrent creates use singleflight", func(t *testing.T) { + mgr, backend := newEnvironmentExecutionTestManager(t, &mockWorkspaceInfoProvider{ + envInfos: map[string]*WorkspaceInfo{ + "env-new": { + TaskID: "task-1", + SessionID: "session-1", + TaskEnvironmentID: "env-new", + WorkspacePath: "/workspace/task-1", + AgentID: "auggie", + }, + }, + }) + backend.delay = 50 * time.Millisecond + + var wg sync.WaitGroup + errs := make(chan error, 2) + ids := make(chan string, 2) + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + execution, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-new") + if err != nil { + errs <- err + return + } + ids <- execution.ID + }() + } + wg.Wait() + close(errs) + close(ids) + + for err := range errs { + t.Fatalf("GetOrEnsureExecutionForEnvironment returned error: %v", err) + } + var firstID string + for id := range ids { + if firstID == "" { + firstID = id + continue + } + if id != firstID { + t.Errorf("execution ID = %q, want %q", id, firstID) + } + } + if backend.createCount.Load() != 1 { + t.Errorf("CreateInstance calls = %d, want 1", backend.createCount.Load()) + } + }) + + t.Run("empty environment ID returns error", func(t *testing.T) { + mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger()} + + _, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "") + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "task_environment_id is required" { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("missing provider returns error instead of session fallback", func(t *testing.T) { + mgr := &Manager{executionStore: NewExecutionStore(), logger: newTestLogger()} + + _, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-missing") + if err == nil { + t.Fatal("expected error") + } + if !containsString(err.Error(), "workspace info provider not configured") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("provider must return matching environment ID", func(t *testing.T) { + provider := &mockWorkspaceInfoProvider{ + envInfos: map[string]*WorkspaceInfo{ + "env-want": { + TaskID: "task-1", + SessionID: "session-1", + TaskEnvironmentID: "env-other", + WorkspacePath: "/tmp/test", + }, + }, + } + mgr := &Manager{ + executionStore: NewExecutionStore(), + workspaceInfoProvider: provider, + logger: newTestLogger(), + } + + _, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-want") + if err == nil { + t.Fatal("expected error") + } + if !containsString(err.Error(), "workspace info resolved environment env-other") { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("provider must return a workspace path", func(t *testing.T) { + provider := &mockWorkspaceInfoProvider{ + envInfos: map[string]*WorkspaceInfo{ + "env-1": { + TaskID: "task-1", + SessionID: "session-1", + TaskEnvironmentID: "env-1", + }, + }, + } + mgr := &Manager{ + executionStore: NewExecutionStore(), + workspaceInfoProvider: provider, + logger: newTestLogger(), + } + + _, err := mgr.GetOrEnsureExecutionForEnvironment(context.Background(), "env-1") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrSessionWorkspaceNotReady) { + t.Errorf("expected ErrSessionWorkspaceNotReady, got %v", err) + } + }) +} + func TestEnsureWorkspaceExecutionForSession_EmptyTaskID(t *testing.T) { t.Run("resolves taskID from provider when empty", func(t *testing.T) { provider := &mockWorkspaceInfoProvider{ @@ -288,6 +540,77 @@ func TestEnsureWorkspaceExecutionForSession_EmptyTaskID(t *testing.T) { // --- test helpers --- +type createInstanceExecutor struct { + MockExecutor + client *agentctl.Client + createCount atomic.Int32 + delay time.Duration +} + +func (e *createInstanceExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { + if e.delay > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(e.delay): + } + } + e.createCount.Add(1) + return &ExecutorInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: string(e.Name()), + Client: e.client, + WorkspacePath: req.WorkspacePath, + }, nil +} + +func newEnvironmentExecutionTestManager(t *testing.T, provider WorkspaceInfoProvider) (*Manager, *createInstanceExecutor) { + t.Helper() + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + backend := &createInstanceExecutor{ + MockExecutor: MockExecutor{name: executor.NameStandalone}, + client: newReadyAgentctlClient(t, log), + } + execRegistry.Register(backend) + + mgr := NewManager( + newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + mgr.workspaceInfoProvider = provider + t.Cleanup(func() { close(mgr.stopCh) }) + return mgr, backend +} + +func newReadyAgentctlClient(t *testing.T, log *logger.Logger) *agentctl.Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server URL: %v", err) + } + host, portString, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatalf("split test server host: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + return agentctl.NewClient(host, port, log) +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && searchString(s, substr) } @@ -312,6 +635,11 @@ func (p *trackingWorkspaceInfoProvider) GetWorkspaceInfoForSession(ctx context.C return p.delegate.GetWorkspaceInfoForSession(ctx, taskID, sessionID) } +func (p *trackingWorkspaceInfoProvider) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*WorkspaceInfo, error) { + *p.called = true + return p.delegate.GetWorkspaceInfoForEnvironment(ctx, taskEnvironmentID) +} + // slowWorkspaceInfoProvider adds a delay to simulate slow DB lookups for concurrency tests. type slowWorkspaceInfoProvider struct { delay time.Duration @@ -328,3 +656,12 @@ func (p *slowWorkspaceInfoProvider) GetWorkspaceInfoForSession(_ context.Context } return p.info, nil } + +func (p *slowWorkspaceInfoProvider) GetWorkspaceInfoForEnvironment(_ context.Context, _ string) (*WorkspaceInfo, error) { + p.callCount.Add(1) + time.Sleep(p.delay) + if p.err != nil { + return nil, p.err + } + return p.info, nil +} diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 16b1a76448..9ed4195a1f 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -634,19 +634,30 @@ func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bo return m.executionStore.GetBySessionID(sessionID) } -// ResolveScopeKey returns a stable scope key for env-keyed resources (user -// shells, etc.) given a sessionID. Returns the session's TaskEnvironmentID -// when an active execution carries it, otherwise falls back to the sessionID -// itself so callers always get a non-empty key. -// -// This is the seam that makes terminals task-keyed: two sessions in the -// same task share an env, so they resolve to the same scope key and the -// runner returns one shared shell list instead of duplicating per-session. -func (m *Manager) ResolveScopeKey(sessionID string) string { - if exec, ok := m.executionStore.GetBySessionID(sessionID); ok && exec.TaskEnvironmentID != "" { - return exec.TaskEnvironmentID +// ResolveTaskEnvironmentID returns the task environment ID for a session. +// User shell resources must be environment-scoped; missing mappings are +// lifecycle errors and must not be converted into session-scoped shell state. +func (m *Manager) ResolveTaskEnvironmentID(ctx context.Context, sessionID string) (string, error) { + if sessionID == "" { + return "", fmt.Errorf("session_id is required") } - return sessionID + if exec, ok := m.executionStore.GetBySessionID(sessionID); ok { + if exec.TaskEnvironmentID != "" { + return exec.TaskEnvironmentID, nil + } + return "", fmt.Errorf("session %s has no task environment ID", sessionID) + } + if m.workspaceInfoProvider == nil { + return "", fmt.Errorf("workspace info provider not configured") + } + info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, "", sessionID) + if err != nil { + return "", fmt.Errorf("failed to resolve task environment for session %s: %w", sessionID, err) + } + if info == nil || info.TaskEnvironmentID == "" { + return "", fmt.Errorf("session %s has no task environment ID", sessionID) + } + return info.TaskEnvironmentID, nil } // IsRemoteSession checks whether a session is associated with a remote executor diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/lifecycle/manager_interaction_test.go index 3d995f7c9b..2b28135997 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction_test.go @@ -517,8 +517,9 @@ func TestIsAgentRunningForSession(t *testing.T) { // --- IsRemoteSession tests --- type mockWorkspaceInfoProvider struct { - infos map[string]*WorkspaceInfo - err error + infos map[string]*WorkspaceInfo + envInfos map[string]*WorkspaceInfo + err error } func (m *mockWorkspaceInfoProvider) GetWorkspaceInfoForSession(_ context.Context, _, sessionID string) (*WorkspaceInfo, error) { @@ -528,6 +529,21 @@ func (m *mockWorkspaceInfoProvider) GetWorkspaceInfoForSession(_ context.Context return m.infos[sessionID], nil } +func (m *mockWorkspaceInfoProvider) GetWorkspaceInfoForEnvironment(_ context.Context, taskEnvironmentID string) (*WorkspaceInfo, error) { + if m.err != nil { + return nil, m.err + } + if m.envInfos != nil { + return m.envInfos[taskEnvironmentID], nil + } + for _, info := range m.infos { + if info.TaskEnvironmentID == taskEnvironmentID { + return info, nil + } + } + return nil, nil +} + func TestIsRemoteSession(t *testing.T) { t.Run("in-memory execution with sprites runtime", func(t *testing.T) { store := NewExecutionStore() diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index de0f1dc18e..c29fd0c02f 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -375,6 +375,8 @@ type WorkspaceInfo struct { type WorkspaceInfoProvider interface { // GetWorkspaceInfoForSession returns workspace info for a specific task session GetWorkspaceInfoForSession(ctx context.Context, taskID, sessionID string) (*WorkspaceInfo, error) + // GetWorkspaceInfoForEnvironment returns workspace info for a task environment. + GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*WorkspaceInfo, error) } // RecoveredExecution contains info about an execution recovered from a runtime. diff --git a/apps/backend/internal/agentctl/server/process/interactive_shells.go b/apps/backend/internal/agentctl/server/process/interactive_shells.go index fdf17ac392..9e2911b924 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_shells.go +++ b/apps/backend/internal/agentctl/server/process/interactive_shells.go @@ -43,8 +43,7 @@ type UserShellInfo struct { // The entry is registered atomically to prevent races with ListUserShells. // // `scopeID` groups shells. Callers should pass `taskEnvironmentID` so sessions -// in the same task share one shell list. Falls back to sessionID is preserved -// at the caller boundary (lifecycle.Manager.ResolveScopeKey). +// in the same task share one shell list. func (r *InteractiveRunner) CreateUserShell(scopeID string) CreateUserShellResult { r.userShellsMu.Lock() defer r.userShellsMu.Unlock() diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index bf4545f795..4490cd9806 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -72,7 +72,7 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) { // Add dedicated terminal WebSocket route if terminal handler is configured if g.TerminalHandler != nil { - router.GET("/terminal/:sessionId", g.TerminalHandler.HandleTerminalWS) + router.GET("/terminal/*target", g.TerminalHandler.HandleTerminalWS) } // Add LSP routes if LSP handler is configured diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index f962625340..c267aa27b8 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -112,7 +112,6 @@ func isResizeCommand(data []byte) bool { const ( passthroughReadyTimeout = 30 * time.Second - shellExecutionTimeout = 15 * time.Second pollInterval = 500 * time.Millisecond ) @@ -155,60 +154,89 @@ func (w *wsWriter) Close() error { return nil } -// HandleTerminalWS handles WebSocket connections at /terminal/:sessionId -// This creates a binary WebSocket bridge between xterm.js and the PTY. -// Query parameter "mode" determines the type of terminal: -// - "agent": Agent passthrough terminal (CLI passthrough mode) -// - "shell": Independent user shell terminal (requires terminalId query param) +type terminalRoute struct { + kind string + id string +} + +const ( + terminalRouteEnvironment = "environment" + terminalRouteSession = "session" +) + +func parseTerminalRoute(c *gin.Context) terminalRoute { + target := strings.Trim(c.Param("target"), "/") + if target == "" { + return terminalRoute{} + } + if id, ok := strings.CutPrefix(target, "environment/"); ok { + return terminalRoute{kind: terminalRouteEnvironment, id: id} + } + if id, ok := strings.CutPrefix(target, "session/"); ok { + return terminalRoute{kind: terminalRouteSession, id: id} + } + return terminalRoute{} +} + +// HandleTerminalWS handles terminal WebSocket connections. +// Explicit routes: +// - /terminal/session/:sessionId?mode=agent for agent passthrough terminals +// - /terminal/environment/:environmentId?terminalId=... for user shell terminals func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) { - sessionID := c.Param("sessionId") + route := parseTerminalRoute(c) + if route.id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "terminal route must be /environment/:environmentId for shell terminals or /session/:sessionId?mode=agent for agent terminals"}) + return + } + + switch route.kind { + case terminalRouteEnvironment: + h.handleEnvironmentTerminalRoute(c, route.id) + case terminalRouteSession: + h.handleSessionTerminalRoute(c, route.id) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid terminal route"}) + } +} + +func (h *TerminalHandler) handleSessionTerminalRoute(c *gin.Context, sessionID string) { + if c.Query("mode") != "agent" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session terminal route requires mode=agent; shell terminals must use /terminal/environment/:environmentId"}) + return + } + h.handleAgentTerminalRoute(c, sessionID) +} + +func (h *TerminalHandler) handleEnvironmentTerminalRoute(c *gin.Context, environmentID string) { + mode := c.Query("mode") + if mode != "" && mode != "shell" { + c.JSON(http.StatusBadRequest, gin.H{"error": "environment terminal route only supports shell mode"}) + return + } + terminalID := c.Query("terminalId") + if terminalID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId required for shell mode"}) + return + } + h.handleEnvironmentUserShellWS(c, environmentID, terminalID) +} + +func (h *TerminalHandler) handleAgentTerminalRoute(c *gin.Context, sessionID string) { if sessionID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) return } - - // Route based on explicit mode parameter - mode := c.Query("mode") - switch mode { - case "agent": - interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() - if interactiveRunner == nil { - h.logger.Error("interactive runner not available") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "interactive runner not available"}) - return - } - h.logger.Info("terminal WebSocket connection request", - zap.String("session_id", sessionID), - zap.String("mode", "agent"), - zap.String("remote_addr", c.Request.RemoteAddr)) - h.handleAgentPassthroughWS(c, sessionID, interactiveRunner) - - case "shell": - terminalID := c.Query("terminalId") - if terminalID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId required for shell mode"}) - return - } - if h.shouldUseWorkspaceShell(c.Request.Context(), sessionID) { - h.handleRemoteUserShellWS(c, sessionID, terminalID) - return - } - interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() - if interactiveRunner == nil { - h.logger.Error("interactive runner not available") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "interactive runner not available"}) - return - } - h.logger.Info("terminal WebSocket connection request", - zap.String("session_id", sessionID), - zap.String("mode", "shell"), - zap.String("terminal_id", terminalID), - zap.String("remote_addr", c.Request.RemoteAddr)) - h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner) - - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "mode query param required: agent or shell"}) + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + h.logger.Error("interactive runner not available") + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "interactive runner not available"}) + return } + h.logger.Info("terminal WebSocket connection request", + zap.String("session_id", sessionID), + zap.String("mode", "agent"), + zap.String("remote_addr", c.Request.RemoteAddr)) + h.handleAgentPassthroughWS(c, sessionID, interactiveRunner) } func (h *TerminalHandler) shouldUseWorkspaceShell(ctx context.Context, sessionID string) bool { @@ -217,56 +245,115 @@ func (h *TerminalHandler) shouldUseWorkspaceShell(ctx context.Context, sessionID return h.lifecycleMgr.ShouldUseContainerShell(ctx, sessionID) } -// waitForRemoteExecution polls the lifecycle manager for the session's execution, -// waiting up to 30 seconds for it to become available. After a backend restart the -// resume flow recreates the execution asynchronously; this bridges the gap so the -// terminal doesn't fall back to a local shell. -func (h *TerminalHandler) waitForRemoteExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, bool) { - return h.waitForRemoteExecutionWithTimeout(ctx, sessionID, 30*time.Second) -} - -func (h *TerminalHandler) waitForRemoteExecutionWithTimeout( +func (h *TerminalHandler) waitForRemoteExecutionReadyWithTimeout( ctx context.Context, sessionID string, timeout time.Duration, ) (*lifecycle.AgentExecution, bool) { - // Fast path: execution already exists. - if execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID); exists { - return execution, true - } + deadline := time.Now().Add(timeout) - h.logger.Info("waiting for execution to become available", + h.logger.Info("waiting for remote execution to become ready", zap.String("session_id", sessionID), zap.Duration("timeout", timeout)) - ticker := time.NewTicker(pollInterval) - defer ticker.Stop() - timer := time.NewTimer(timeout) - defer timer.Stop() - for { - select { - case <-ctx.Done(): - return nil, false - case <-timer.C: - h.logger.Warn("timed out waiting for execution", + if execution, ok := h.remoteExecutionWithClientReady(ctx, sessionID, deadline); ok { + return execution, true + } + + remaining := time.Until(deadline) + if remaining <= 0 { + h.logger.Warn("timed out waiting for remote execution readiness", zap.String("session_id", sessionID), zap.Duration("timeout", timeout)) return nil, false - case <-ticker.C: - if execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID); exists { - h.logger.Info("execution became available", - zap.String("session_id", sessionID), - zap.String("execution_id", execution.ID)) - return execution, true - } + } + + wait := pollInterval + if remaining < wait { + wait = remaining + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return nil, false + case <-timer.C: } } } -func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, terminalID string) { - execution, exists := h.waitForRemoteExecution(c.Request.Context(), sessionID) +func (h *TerminalHandler) remoteExecutionWithClientReady( + ctx context.Context, + sessionID string, + deadline time.Time, +) (*lifecycle.AgentExecution, bool) { + execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID) if !exists || execution.GetAgentCtlClient() == nil { + return nil, false + } + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, false + } + if err := execution.GetAgentCtlClient().WaitForReady(ctx, remaining); err != nil { + h.logger.Debug("remote execution agentctl client not ready", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID), + zap.Error(err)) + return nil, false + } + h.logger.Info("remote execution became ready", + zap.String("session_id", sessionID), + zap.String("execution_id", execution.ID)) + return execution, true +} + +func (h *TerminalHandler) handleEnvironmentUserShellWS(c *gin.Context, environmentID, terminalID string) { + execution, err := h.lifecycleMgr.GetOrEnsureExecutionForEnvironment(c.Request.Context(), environmentID) + if err != nil { + h.logger.Warn("environment terminal execution not ready", + zap.String("task_environment_id", environmentID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + if h.shouldUseWorkspaceShell(c.Request.Context(), execution.SessionID) { + remoteExecution, ok := h.waitForRemoteExecutionReadyWithTimeout( + c.Request.Context(), execution.SessionID, passthroughReadyTimeout, + ) + if !ok { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "remote execution not available"}) + return + } + execution = remoteExecution + h.handleRemoteUserShellWS(c, execution, environmentID, terminalID) + return + } + interactiveRunner := h.lifecycleMgr.GetInteractiveRunner() + if interactiveRunner == nil { + h.logger.Error("interactive runner not available") + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "interactive runner not available"}) + return + } + h.logger.Info("terminal WebSocket connection request", + zap.String("task_environment_id", environmentID), + zap.String("session_id", execution.SessionID), + zap.String("mode", "shell"), + zap.String("terminal_id", terminalID), + zap.String("remote_addr", c.Request.RemoteAddr)) + h.handleUserShellWS(c, execution, environmentID, terminalID, interactiveRunner) +} + +func (h *TerminalHandler) handleRemoteUserShellWS( + c *gin.Context, + execution *lifecycle.AgentExecution, + scopeID string, + terminalID string, +) { + sessionID := execution.SessionID + if execution.GetAgentCtlClient() == nil { c.JSON(http.StatusNotFound, gin.H{"error": "remote execution not available"}) return } @@ -277,13 +364,10 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter c.JSON(http.StatusBadRequest, gin.H{"error": httpErr}) return } - // The per-terminal WS URL only carries terminalId, so fall back to the - // command pre-registered by wsUserShellCreate when the URL didn't include - // scriptId or label-derived command. Without this, script terminals (custom - // scripts and dev_script) would open empty in containerized sessions. + // The per-terminal WS URL carries only env + terminalId, so recover the + // command pre-registered by wsUserShellCreate for script/dev terminals. if initialCommand == "" { if runner := h.lifecycleMgr.GetInteractiveRunner(); runner != nil { - scopeID := scopeIDForExecution(execution, sessionID) initialCommand = runner.LookupShellInitialCommand(scopeID, terminalID) } } @@ -326,6 +410,7 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter }() h.logger.Info("remote terminal shell WebSocket connected", + zap.String("task_environment_id", scopeID), zap.String("session_id", sessionID), zap.String("terminal_id", terminalID)) @@ -850,47 +935,20 @@ func (h *TerminalHandler) resolvePreferredShell(ctx context.Context) string { return shell } -// resolveWorkingDir returns the workspace path for the given session. -// It waits briefly for execution/workspace readiness to avoid launching shells in the wrong cwd. -func (h *TerminalHandler) resolveWorkingDir(ctx context.Context, sessionID string) (string, error) { - execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID) - if exists && execution.WorkspacePath != "" { - h.logger.Info("handleUserShellWS: got working directory from execution", - zap.String("working_dir", execution.WorkspacePath)) - return execution.WorkspacePath, nil - } - - h.logger.Info("handleUserShellWS: waiting for execution workspace path", - zap.String("session_id", sessionID), - zap.Duration("timeout", shellExecutionTimeout)) - - execution, exists = h.waitForRemoteExecutionWithTimeout(ctx, sessionID, shellExecutionTimeout) - if !exists || execution == nil { - return "", fmt.Errorf("session %s execution is not ready yet", sessionID) +// resolveWorkingDir returns the workspace path from the environment execution. +func (h *TerminalHandler) resolveWorkingDir(execution *lifecycle.AgentExecution) (string, error) { + if execution == nil { + return "", fmt.Errorf("environment execution is not ready") } if execution.WorkspacePath == "" { - return "", fmt.Errorf("%w: session %s has no workspace path configured", lifecycle.ErrSessionWorkspaceNotReady, sessionID) + return "", fmt.Errorf("%w: task environment %s has no workspace path configured", + lifecycle.ErrSessionWorkspaceNotReady, execution.TaskEnvironmentID) } - - h.logger.Info("handleUserShellWS: got working directory after wait", + h.logger.Info("handleUserShellWS: got working directory from environment execution", zap.String("working_dir", execution.WorkspacePath)) return execution.WorkspacePath, nil } -func (h *TerminalHandler) resolveScopeID(ctx context.Context, sessionID string) (string, error) { - if _, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID); err != nil { - return "", fmt.Errorf("failed to ensure execution for scope: %w", err) - } - return h.lifecycleMgr.ResolveScopeKey(sessionID), nil -} - -func scopeIDForExecution(execution *lifecycle.AgentExecution, sessionID string) string { - if execution != nil && execution.TaskEnvironmentID != "" { - return execution.TaskEnvironmentID - } - return sessionID -} - // resolveShellLabel returns the label and initial command for a user shell, derived // from either a script ID lookup or a plain label query parameter. // Returns an HTTP error string (non-empty) when the script lookup fails. @@ -915,13 +973,15 @@ func (h *TerminalHandler) resolveShellLabel(c *gin.Context) (label, initialComma // user shell process. Returns the process ID on success, or an HTTP status + message on failure. func (h *TerminalHandler) startUserShellProcess( c *gin.Context, - sessionID string, + execution *lifecycle.AgentExecution, + scopeID string, terminalID string, interactiveRunner *process.InteractiveRunner, -) (processID string, scopeID string, httpStatus int, errMsg string) { +) (string, int, string) { + sessionID := execution.SessionID label, initialCommand, httpErr := h.resolveShellLabel(c) if httpErr != "" { - return "", "", http.StatusBadRequest, httpErr + return "", http.StatusBadRequest, httpErr } h.logger.Info("handleUserShellWS: starting user shell handling", @@ -930,21 +990,13 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("label", label), zap.String("initial_command", initialCommand)) - scopeID, err := h.resolveScopeID(c.Request.Context(), sessionID) - if err != nil { - h.logger.Warn("handleUserShellWS: scope not ready", - zap.String("session_id", sessionID), - zap.Error(err)) - return "", "", http.StatusServiceUnavailable, err.Error() - } - preferredShell := h.resolvePreferredShell(c.Request.Context()) - workingDir, err := h.resolveWorkingDir(c.Request.Context(), sessionID) + workingDir, err := h.resolveWorkingDir(execution) if err != nil { h.logger.Warn("handleUserShellWS: workspace path not ready", zap.String("session_id", sessionID), zap.Error(err)) - return "", "", http.StatusServiceUnavailable, err.Error() + return "", http.StatusServiceUnavailable, err.Error() } opts := &process.UserShellOptions{Label: label, InitialCommand: initialCommand} @@ -965,7 +1017,7 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.Error(err)) - return "", "", http.StatusServiceUnavailable, err.Error() + return "", http.StatusServiceUnavailable, err.Error() } h.logger.Info("handleUserShellWS: user shell started successfully", @@ -973,18 +1025,22 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("terminal_id", terminalID), zap.String("process_id", info.ID)) - return info.ID, scopeID, 0, "" + return info.ID, 0, "" } // handleUserShellWS handles WebSocket connections for user shell terminals. // Each terminal tab gets its own independent shell process. func (h *TerminalHandler) handleUserShellWS( c *gin.Context, - sessionID string, + execution *lifecycle.AgentExecution, + scopeID string, terminalID string, interactiveRunner *process.InteractiveRunner, ) { - processID, scopeID, httpStatus, errMsg := h.startUserShellProcess(c, sessionID, terminalID, interactiveRunner) + sessionID := execution.SessionID + processID, httpStatus, errMsg := h.startUserShellProcess( + c, execution, scopeID, terminalID, interactiveRunner, + ) if errMsg != "" { c.JSON(httpStatus, gin.H{"error": errMsg}) return diff --git a/apps/backend/internal/gateway/websocket/terminal_handler_test.go b/apps/backend/internal/gateway/websocket/terminal_handler_test.go index ff782d7104..d32fd746a2 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler_test.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler_test.go @@ -2,9 +2,13 @@ package websocket import ( "bytes" + "encoding/json" "net/http" + "net/http/httptest" "net/url" "testing" + + "github.com/gin-gonic/gin" ) func TestCheckWebSocketOrigin(t *testing.T) { @@ -145,3 +149,58 @@ func TestStripTerminalResponses(t *testing.T) { }) } } + +func TestParseTerminalRoute(t *testing.T) { + tests := []struct { + name string + target string + wantKind string + wantID string + }{ + {name: "environment route", target: "/environment/env-1", wantKind: terminalRouteEnvironment, wantID: "env-1"}, + {name: "session route", target: "/session/session-1", wantKind: terminalRouteSession, wantID: "session-1"}, + {name: "unknown route", target: "/unknown-target", wantKind: "", wantID: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTerminalRoute(&gin.Context{Params: gin.Params{{Key: "target", Value: tt.target}}}) + if got.kind != tt.wantKind || got.id != tt.wantID { + t.Fatalf("parseTerminalRoute() = {%q %q}, want {%q %q}", + got.kind, got.id, tt.wantKind, tt.wantID) + } + }) + } +} + +func TestSessionTerminalRouteRequiresAgentMode(t *testing.T) { + tests := []struct { + name string + query string + }{ + {name: "missing mode", query: ""}, + {name: "shell mode", query: "?mode=shell"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/terminal/session/session-1"+tt.query, nil) + + handler := &TerminalHandler{} + handler.handleSessionTerminalRoute(c, "session-1") + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest) + } + + var body map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["error"] != "session terminal route requires mode=agent; shell terminals must use /terminal/environment/:environmentId" { + t.Fatalf("error = %q", body["error"]) + } + }) + } +} diff --git a/apps/backend/internal/gateway/websocket/terminal_handler_wait_test.go b/apps/backend/internal/gateway/websocket/terminal_handler_wait_test.go index ca8a10b14d..0d66cfd06a 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler_wait_test.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler_wait_test.go @@ -62,7 +62,16 @@ func newTestTerminalHandler(t *testing.T) (*TerminalHandler, *lifecycle.Executio return handler, mgr.ExecutionStoreForTesting() } -func TestWaitForRemoteExecutionWithTimeout_FastPath(t *testing.T) { +func TestWaitForRemoteExecutionReadyWithTimeout_Timeout(t *testing.T) { + handler, _ := newTestTerminalHandler(t) + + _, ok := handler.waitForRemoteExecutionReadyWithTimeout(context.Background(), "session-missing", 600*time.Millisecond) + if ok { + t.Fatal("expected timeout, got execution") + } +} + +func TestWaitForRemoteExecutionReadyWithTimeout_NoClient(t *testing.T) { handler, store := newTestTerminalHandler(t) store.Add(&lifecycle.AgentExecution{ @@ -71,53 +80,19 @@ func TestWaitForRemoteExecutionWithTimeout_FastPath(t *testing.T) { Status: v1.AgentStatusRunning, }) - got, ok := handler.waitForRemoteExecutionWithTimeout(context.Background(), "session-1", 1*time.Second) - if !ok { - t.Fatal("expected fast path to find execution") - } - if got.ID != "exec-1" { - t.Errorf("expected exec-1, got %s", got.ID) - } -} - -func TestWaitForRemoteExecutionWithTimeout_PollSuccess(t *testing.T) { - handler, store := newTestTerminalHandler(t) - - // Add execution after a short delay (simulates async workspace setup) - go func() { - time.Sleep(200 * time.Millisecond) - store.Add(&lifecycle.AgentExecution{ - ID: "exec-delayed", - SessionID: "session-1", - Status: v1.AgentStatusRunning, - }) - }() - - got, ok := handler.waitForRemoteExecutionWithTimeout(context.Background(), "session-1", 5*time.Second) - if !ok { - t.Fatal("expected polling to find execution") - } - if got.ID != "exec-delayed" { - t.Errorf("expected exec-delayed, got %s", got.ID) - } -} - -func TestWaitForRemoteExecutionWithTimeout_Timeout(t *testing.T) { - handler, _ := newTestTerminalHandler(t) - - _, ok := handler.waitForRemoteExecutionWithTimeout(context.Background(), "session-missing", 600*time.Millisecond) + _, ok := handler.waitForRemoteExecutionReadyWithTimeout(context.Background(), "session-1", 600*time.Millisecond) if ok { - t.Fatal("expected timeout, got execution") + t.Fatal("expected timeout without agentctl client") } } -func TestWaitForRemoteExecutionWithTimeout_ContextCancelled(t *testing.T) { +func TestWaitForRemoteExecutionReadyWithTimeout_ContextCancelled(t *testing.T) { handler, _ := newTestTerminalHandler(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, ok := handler.waitForRemoteExecutionWithTimeout(ctx, "session-1", 5*time.Second) + _, ok := handler.waitForRemoteExecutionReadyWithTimeout(ctx, "session-1", 5*time.Second) if ok { t.Fatal("expected context cancellation to return false") } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index a427174ae8..3c3181fa72 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -98,18 +98,19 @@ func createTestService(t *testing.T) (*Service, *MockEventBus, *sqliterepo.Repos eventBus := NewMockEventBus() log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) svc := NewService(Repos{ - Workspaces: repo, - Tasks: repo, - TaskRepos: repo, - Workflows: repo, - Messages: repo, - Turns: repo, - Sessions: repo, - GitSnapshots: repo, - RepoEntities: repo, - Executors: repo, - Environments: repo, - Reviews: repo, + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, }, eventBus, log, RepositoryDiscoveryConfig{}) return svc, eventBus, repo } diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 8d86af2261..5cca5e15c7 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "sort" "time" "github.com/google/uuid" @@ -287,6 +288,43 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio return info, nil } +// GetWorkspaceInfoForEnvironment returns workspace information for a task environment. +func (s *Service) GetWorkspaceInfoForEnvironment(ctx context.Context, taskEnvironmentID string) (*lifecycle.WorkspaceInfo, error) { + if taskEnvironmentID == "" { + return nil, fmt.Errorf("task_environment_id is required") + } + env, err := s.taskEnvironments.GetTaskEnvironment(ctx, taskEnvironmentID) + if err != nil { + return nil, err + } + if env == nil { + return nil, fmt.Errorf("task environment %s not found", taskEnvironmentID) + } + sessions, err := s.sessions.ListTaskSessions(ctx, env.TaskID) + if err != nil { + return nil, fmt.Errorf("failed to list sessions for task environment %s: %w", taskEnvironmentID, err) + } + matching := make([]*models.TaskSession, 0, len(sessions)) + for _, session := range sessions { + if session.TaskEnvironmentID == taskEnvironmentID { + matching = append(matching, session) + } + } + if len(matching) > 0 { + sort.SliceStable(matching, func(i, j int) bool { + if !matching[i].StartedAt.Equal(matching[j].StartedAt) { + return matching[i].StartedAt.After(matching[j].StartedAt) + } + if !matching[i].UpdatedAt.Equal(matching[j].UpdatedAt) { + return matching[i].UpdatedAt.After(matching[j].UpdatedAt) + } + return matching[i].ID > matching[j].ID + }) + return s.GetWorkspaceInfoForSession(ctx, env.TaskID, matching[0].ID) + } + return nil, fmt.Errorf("task environment %s has no linked task session", taskEnvironmentID) +} + func isExecutorRunningNotFoundError(err error) bool { if err == nil { return false diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index bc6c9c42b6..05d1a4e150 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -212,3 +212,57 @@ func TestGetWorkspaceInfoForSession_SessionNotFound(t *testing.T) { t.Fatal("expected error for nonexistent session") } } + +func TestGetWorkspaceInfoForEnvironment(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-123", + TaskID: "task-123", + Status: models.TaskEnvironmentStatusReady, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create task environment: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + State: models.TaskSessionStateCompleted, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "auggie", + }, + StartedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to create session: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-2", + TaskID: "task-123", + TaskEnvironmentID: "env-123", + State: models.TaskSessionStateCompleted, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "auggie", + }, + StartedAt: now.Add(time.Minute), + UpdatedAt: now.Add(time.Minute), + }); err != nil { + t.Fatalf("failed to create newer session: %v", err) + } + + info, err := svc.GetWorkspaceInfoForEnvironment(ctx, "env-123") + if err != nil { + t.Fatalf("GetWorkspaceInfoForEnvironment returned error: %v", err) + } + if info.SessionID != "session-2" { + t.Errorf("SessionID = %q, want session-2", info.SessionID) + } + if info.TaskEnvironmentID != "env-123" { + t.Errorf("TaskEnvironmentID = %q, want env-123", info.TaskEnvironmentID) + } +} diff --git a/apps/web/components/task/bottom-terminal-panel.tsx b/apps/web/components/task/bottom-terminal-panel.tsx index a36fb77e35..ebea52634c 100644 --- a/apps/web/components/task/bottom-terminal-panel.tsx +++ b/apps/web/components/task/bottom-terminal-panel.tsx @@ -3,7 +3,7 @@ import { useRef, useState, useCallback, useEffect } from "react"; import { IconMinus } from "@tabler/icons-react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; +import { useEnvironmentId } from "@/hooks/use-environment-session-id"; import { PassthroughTerminal } from "./passthrough-terminal"; import { Button } from "@kandev/ui/button"; import { cn } from "@/lib/utils"; @@ -15,10 +15,7 @@ const STORAGE_KEY_HEIGHT = "bottom-terminal-height"; const STORAGE_KEY_OPEN = "bottom-terminal-open"; export function BottomTerminalPanel() { - // Pin to a stable sessionId across same-task session switches so the bottom - // terminal doesn't reconnect / land on a different shell. Matches the - // dockview terminal panel's behavior in `terminal-panel.tsx`. - const sessionId = useEnvironmentSessionId(); + const environmentId = useEnvironmentId(); const visible = useAppStore((s) => s.bottomTerminal.isOpen); const pendingCommand = useAppStore((s) => s.bottomTerminal.pendingCommand); const storeApi = useAppStoreApi(); @@ -98,8 +95,8 @@ export function BottomTerminalPanel() { {/* Terminal content */}