From 50b3543bf11be015e36e3cd2559428f6edefc1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 27 Feb 2026 16:00:11 +0000 Subject: [PATCH] feat: improve session restoration for complete/failed/cancelled (#302) --- apps/backend/cmd/kandev/adapters.go | 32 +-- .../agent/lifecycle/executor_backend.go | 24 +- .../agent/lifecycle/executor_sprites.go | 16 +- .../agent/lifecycle/manager_execution.go | 20 +- .../agent/lifecycle/manager_launch.go | 21 +- .../backend/internal/agent/lifecycle/types.go | 11 +- .../simulated_agent_manager_test.go | 3 + apps/backend/internal/orchestrator/dto/dto.go | 9 +- .../orchestrator/event_handlers_test.go | 3 + .../orchestrator/executor/executor.go | 34 +-- .../orchestrator/executor/executor_resume.go | 20 +- .../orchestrator/executor/executor_test.go | 3 + .../orchestrator/scheduler/scheduler_test.go | 3 + .../internal/orchestrator/session_launch.go | 50 ++++- .../orchestrator/session_launch_test.go | 110 +++++++++ .../internal/orchestrator/task_operations.go | 6 + .../orchestrator/task_operations_test.go | 98 ++++++++ .../internal/task/service/service_turns.go | 28 ++- .../task/service/service_turns_test.go | 210 ++++++++++++++++++ .../domains/session/use-session-resumption.ts | 78 ++++--- .../lib/services/session-launch-helpers.ts | 11 + .../lib/services/session-launch-service.ts | 2 +- 22 files changed, 667 insertions(+), 125 deletions(-) create mode 100644 apps/backend/internal/task/service/service_turns_test.go diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 2675324ac4..030be5d989 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -68,19 +68,20 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch // The RepositoryURL field contains a local filesystem path for the workspace // If empty, the agent will run without a mounted workspace launchReq := &lifecycle.LaunchRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - TaskTitle: req.TaskTitle, - AgentProfileID: req.AgentProfileID, - WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this - TaskDescription: req.TaskDescription, - Env: req.Env, - ACPSessionID: req.ACPSessionID, - Metadata: req.Metadata, - ModelOverride: req.ModelOverride, - ExecutorType: req.ExecutorType, - ExecutorConfig: req.ExecutorConfig, - SetupScript: req.SetupScript, + TaskID: req.TaskID, + SessionID: req.SessionID, + TaskTitle: req.TaskTitle, + AgentProfileID: req.AgentProfileID, + WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this + TaskDescription: req.TaskDescription, + Env: req.Env, + ACPSessionID: req.ACPSessionID, + Metadata: req.Metadata, + ModelOverride: req.ModelOverride, + ExecutorType: req.ExecutorType, + ExecutorConfig: req.ExecutorConfig, + PreviousExecutionID: req.PreviousExecutionID, + SetupScript: req.SetupScript, // Worktree configuration for concurrent agent execution UseWorktree: req.UseWorktree, RepositoryID: req.RepositoryID, @@ -238,6 +239,11 @@ func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID) } +func (a *lifecycleAdapter) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error { + _, err := a.mgr.EnsureWorkspaceExecutionForSession(ctx, taskID, sessionID) + return err +} + func (a *lifecycleAdapter) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*executor.RemoteRuntimeStatus, error) { status, ok := a.mgr.GetRemoteStatusBySessionID(ctx, sessionID) if !ok || status == nil { diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 3389467388..fc4cd9c200 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -64,9 +64,6 @@ const ( MetadataKeyRemoteAuthHome = "remote_auth_target_home" MetadataKeyGitUserName = "git_user_name" MetadataKeyGitUserEmail = "git_user_email" - MetadataKeyRemoteReconnect = "remote_reconnect_required" - MetadataKeyRemoteName = "remote_reconnect_name" - MetadataKeyRemoteExecID = "remote_previous_execution_id" ) // RemoteStatus describes runtime health/details for remote executors. @@ -95,16 +92,17 @@ type RemoteStatusProvider interface { // ExecutorCreateRequest contains parameters for creating an agentctl instance. type ExecutorCreateRequest struct { - InstanceID string - TaskID string - SessionID string - AgentProfileID string - WorkspacePath string - Protocol string - Env map[string]string - Metadata map[string]interface{} - McpServers []McpServerConfig - AgentConfig agents.Agent // Agent type info needed by runtimes + InstanceID string + TaskID string + SessionID string + AgentProfileID string + WorkspacePath string + Protocol string + Env map[string]string + Metadata map[string]interface{} + McpServers []McpServerConfig + AgentConfig agents.Agent // Agent type info needed by runtimes + PreviousExecutionID string // Non-empty when reconnecting to a previous execution // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go index 3ca912f7d5..ca7e604fbe 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -124,12 +124,6 @@ func (r *SpritesExecutor) ResumeRemoteInstance(_ context.Context, req *ExecutorC if req == nil { return fmt.Errorf("request is nil") } - if !getMetadataBool(req.Metadata, MetadataKeyRemoteReconnect) { - return nil - } - if strings.TrimSpace(getMetadataString(req.Metadata, MetadataKeyRemoteName)) == "" { - return fmt.Errorf("remote reconnect requested without sprite name") - } return nil } @@ -145,9 +139,13 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat spriteName := spritesNamePrefix + req.InstanceID[:12] client := sprites.New(token, sprites.WithDisableControl()) - reconnectRequired := getMetadataBool(req.Metadata, MetadataKeyRemoteReconnect) - if reconnectName := strings.TrimSpace(getMetadataString(req.Metadata, MetadataKeyRemoteName)); reconnectName != "" { - spriteName = reconnectName + reconnectRequired := req.PreviousExecutionID != "" + if reconnectRequired { + suffix := req.PreviousExecutionID + if len(suffix) > 12 { + suffix = suffix[:12] + } + spriteName = spritesNamePrefix + suffix } r.logger.Info("creating sprite instance", diff --git a/apps/backend/internal/agent/lifecycle/manager_execution.go b/apps/backend/internal/agent/lifecycle/manager_execution.go index 431b3b99d9..94ac77563b 100644 --- a/apps/backend/internal/agent/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/lifecycle/manager_execution.go @@ -203,8 +203,8 @@ func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profi // createExecution creates an agentctl execution. // The agent subprocess is NOT started - call ConfigureAgent + Start explicitly. func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { - // Get the default runtime for on-demand execution creation - rt, err := m.getDefaultExecutorBackend() + // Select runtime based on executor type; falls back to standalone if empty/unavailable + rt, err := m.getExecutorBackend(info.ExecutorType) if err != nil { return nil, fmt.Errorf("no runtime configured: %w", err) } @@ -221,13 +221,15 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work } req := &ExecutorCreateRequest{ - InstanceID: executionID, - TaskID: taskID, - SessionID: info.SessionID, - AgentProfileID: info.AgentProfileID, - WorkspacePath: info.WorkspacePath, - Protocol: string(agentConfig.Runtime().Protocol), - AgentConfig: agentConfig, + InstanceID: executionID, + TaskID: taskID, + SessionID: info.SessionID, + AgentProfileID: info.AgentProfileID, + WorkspacePath: info.WorkspacePath, + Protocol: string(agentConfig.Runtime().Protocol), + AgentConfig: agentConfig, + Metadata: info.Metadata, + PreviousExecutionID: info.AgentExecutionID, } runtimeInstance, err := rt.CreateInstance(ctx, req) diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index b7ad9a5e16..9bf4543f3c 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -183,16 +183,17 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st metadata := buildLaunchMetadata(reqWithWorktree, mainRepoGitDir, worktreeID, worktreeBranch) execReq := &ExecutorCreateRequest{ - InstanceID: executionID, - TaskID: reqWithWorktree.TaskID, - SessionID: reqWithWorktree.SessionID, - AgentProfileID: reqWithWorktree.AgentProfileID, - WorkspacePath: reqWithWorktree.WorkspacePath, - Protocol: string(agentConfig.Runtime().Protocol), - Env: env, - Metadata: metadata, - AgentConfig: agentConfig, - McpServers: mcpServers, + InstanceID: executionID, + TaskID: reqWithWorktree.TaskID, + SessionID: reqWithWorktree.SessionID, + AgentProfileID: reqWithWorktree.AgentProfileID, + WorkspacePath: reqWithWorktree.WorkspacePath, + Protocol: string(agentConfig.Runtime().Protocol), + Env: env, + Metadata: metadata, + AgentConfig: agentConfig, + McpServers: mcpServers, + PreviousExecutionID: reqWithWorktree.PreviousExecutionID, OnProgress: func(step PrepareStep, stepIndex int, totalSteps int) { m.eventPublisher.PublishPrepareProgress(reqWithWorktree.SessionID, &PrepareProgressEventPayload{ TaskID: reqWithWorktree.TaskID, diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 93ffc693a2..5cb2f37205 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -171,8 +171,9 @@ type LaunchRequest struct { ModelOverride string // If set, use this model instead of the profile's model // Executor configuration - determines which runtime to use - ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime - ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + PreviousExecutionID string // Previous execution ID for runtime reconnect // Environment preparation SetupScript string // Setup script to run before agent starts @@ -240,6 +241,12 @@ type WorkspaceInfo struct { AgentProfileID string // Optional - agent profile for the task AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) + + // Executor-aware fields for correct runtime selection and remote reconnection + ExecutorType string // Executor type (e.g., "local_pc", "sprites") + RuntimeName string // Runtime name from ExecutorRunning record + AgentExecutionID string // Previous execution ID (for remote reconnect) + Metadata map[string]interface{} // Additional metadata (reconnect flags) } // WorkspaceInfoProvider provides workspace information for tasks diff --git a/apps/backend/internal/integration/simulated_agent_manager_test.go b/apps/backend/internal/integration/simulated_agent_manager_test.go index 627c016a9e..00e5b697e1 100644 --- a/apps/backend/internal/integration/simulated_agent_manager_test.go +++ b/apps/backend/internal/integration/simulated_agent_manager_test.go @@ -418,3 +418,6 @@ func (s *SimulatedAgentManagerClient) PollRemoteStatusForRecords(ctx context.Con func (s *SimulatedAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } +func (s *SimulatedAgentManagerClient) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error { + return nil +} diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 13d5611394..8c7eb8e395 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -95,10 +95,11 @@ type TaskSessionStatusResponse struct { AgentProfileID string `json:"agent_profile_id,omitempty"` // Runtime status - IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running - IsResumable bool `json:"is_resumable"` // Session can be resumed - NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) - ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") + IsAgentRunning bool `json:"is_agent_running"` // Agent process is currently running + IsResumable bool `json:"is_resumable"` // Session can be resumed + NeedsResume bool `json:"needs_resume"` // Session needs resumption (page reload scenario) + NeedsWorkspaceRestore bool `json:"needs_workspace_restore"` // Session workspace can be restored (terminal state) + ResumeReason string `json:"resume_reason,omitempty"` // Why resume is needed (e.g., "agent_not_running") // ACP session info ACPSessionID string `json:"acp_session_id,omitempty"` diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 476e17d6cc..8d2fcf0896 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -147,6 +147,9 @@ func (m *mockAgentManager) PollRemoteStatusForRecords(_ context.Context, _ []exe func (m *mockAgentManager) CleanupStaleExecutionBySessionID(_ context.Context, _ string) error { return nil } +func (m *mockAgentManager) EnsureWorkspaceExecutionForSession(_ context.Context, _, _ string) error { + return nil +} // --- Helpers --- diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 7ce871024b..7aabfbaca5 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -114,6 +114,11 @@ type AgentManagerClient interface { // tracking store. Used when the agent process has exited but the execution entry // was not cleaned up (e.g. prepared workspace where agent was never started). CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error + + // EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a + // session so that workspace operations (file tree, terminals, git) are accessible. + // Used for restoring workspace access on terminal-state sessions. + EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error } // RemoteRuntimeStatus mirrors runtime status details needed by orchestrator/UI. @@ -150,20 +155,21 @@ type AgentProfileInfo struct { // LaunchAgentRequest contains parameters for launching an agent type LaunchAgentRequest struct { - TaskID string - SessionID string - TaskTitle string // Human-readable task title for semantic worktree naming - AgentProfileID string - RepositoryURL string - Branch string - TaskDescription string // Task description to send via ACP prompt - Priority int - Metadata map[string]interface{} - Env map[string]string - ACPSessionID string // ACP session ID to resume, if available - ModelOverride string // If set, use this model instead of the profile's model - ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime - ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + TaskID string + SessionID string + TaskTitle string // Human-readable task title for semantic worktree naming + AgentProfileID string + RepositoryURL string + Branch string + TaskDescription string // Task description to send via ACP prompt + Priority int + Metadata map[string]interface{} + Env map[string]string + ACPSessionID string // ACP session ID to resume, if available + ModelOverride string // If set, use this model instead of the profile's model + ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime + ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) + PreviousExecutionID string // Previous execution ID for runtime reconnect // Setup script from executor profile (runs in execution environment before agent starts) SetupScript string diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index e385b4c4de..aa8cee148b 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -334,13 +334,8 @@ func (e *Executor) applyRunningRecordToResumeRequest(ctx context.Context, req *L return nil } - if req.ExecutorType == string(models.ExecutorTypeSprites) && running.AgentExecutionID != "" { - if req.Metadata == nil { - req.Metadata = make(map[string]interface{}) - } - req.Metadata[lifecycle.MetadataKeyRemoteReconnect] = true - req.Metadata[lifecycle.MetadataKeyRemoteExecID] = running.AgentExecutionID - req.Metadata[lifecycle.MetadataKeyRemoteName] = spriteNameFromExecutionID(running.AgentExecutionID) + if running.AgentExecutionID != "" { + req.PreviousExecutionID = running.AgentExecutionID } if running.ResumeToken != "" && startAgent { @@ -363,17 +358,6 @@ func (e *Executor) applyRunningRecordToResumeRequest(ctx context.Context, req *L return running } -func spriteNameFromExecutionID(executionID string) string { - if executionID == "" { - return "" - } - trimmed := executionID - if len(trimmed) > 12 { - trimmed = trimmed[:12] - } - return "kandev-" + trimmed -} - // applyResumeRepoConfig resolves repository details and applies them to req. // Returns the resolved repositoryID. func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, session *models.TaskSession, req *LaunchAgentRequest) (string, error) { diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 4af20585fb..ecd0f05f13 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -89,6 +89,9 @@ func (m *mockAgentManager) PollRemoteStatusForRecords(ctx context.Context, recor func (m *mockAgentManager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } +func (m *mockAgentManager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error { + return nil +} func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) { if m.resolveAgentProfileFunc != nil { diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index e8f0d11cf4..d572bbed26 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -88,6 +88,9 @@ func (m *mockAgentManager) PollRemoteStatusForRecords(ctx context.Context, recor func (m *mockAgentManager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error { return nil } +func (m *mockAgentManager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) error { + return nil +} func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) error { return nil diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index bd1593b365..7ecd86e186 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -16,11 +16,12 @@ import ( type SessionIntent string const ( - IntentPrepare SessionIntent = "prepare" // Create session, optionally launch workspace, NO agent - IntentStart SessionIntent = "start" // Create session + launch agent (new session) - IntentStartCreated SessionIntent = "start_created" // Start agent on existing CREATED session - IntentResume SessionIntent = "resume" // Restart stopped session with resume token - IntentWorkflowStep SessionIntent = "workflow_step" // Start session with workflow step prompt config + IntentPrepare SessionIntent = "prepare" // Create session, optionally launch workspace, NO agent + IntentStart SessionIntent = "start" // Create session + launch agent (new session) + IntentStartCreated SessionIntent = "start_created" // Start agent on existing CREATED session + IntentResume SessionIntent = "resume" // Restart stopped session with resume token + IntentWorkflowStep SessionIntent = "workflow_step" // Start session with workflow step prompt config + IntentRestoreWorkspace SessionIntent = "restore_workspace" // Restore workspace access for terminal-state session ) // LaunchSessionRequest is the unified request for session.launch. @@ -91,6 +92,8 @@ func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) return s.launchResume(ctx, req) case IntentWorkflowStep: return s.launchWorkflowStep(ctx, req) + case IntentRestoreWorkspace: + return s.launchRestoreWorkspace(ctx, req) default: return nil, fmt.Errorf("unknown intent: %s", intent) } @@ -161,6 +164,43 @@ func (s *Service) launchWorkflowStep(ctx context.Context, req *LaunchSessionRequ }, nil } +// launchRestoreWorkspace restores workspace access for a terminal-state session (COMPLETED, FAILED, CANCELLED). +// It creates a lightweight agentctl execution so the frontend can browse files, open terminals, and view git status. +func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required for workspace restore") + } + + session, err := s.repo.GetTaskSession(ctx, req.SessionID) + if err != nil { + return nil, fmt.Errorf("session not found: %w", err) + } + if session.TaskID != req.TaskID { + return nil, fmt.Errorf("session does not belong to task") + } + + if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil { + return nil, fmt.Errorf("failed to restore workspace: %w", err) + } + + resp := &LaunchSessionResponse{ + Success: true, + TaskID: req.TaskID, + SessionID: req.SessionID, + State: string(session.State), + } + if len(session.Worktrees) > 0 { + wt := session.Worktrees[0] + if wt.WorktreePath != "" { + resp.WorktreePath = &wt.WorktreePath + } + if wt.WorktreeBranch != "" { + resp.WorktreeBranch = &wt.WorktreeBranch + } + } + return resp, nil +} + // executionToLaunchResponse converts a TaskExecution to a LaunchSessionResponse. func executionToLaunchResponse(taskID string, exec *executor.TaskExecution) *LaunchSessionResponse { resp := &LaunchSessionResponse{ diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index eca59bb01e..00cf5290f8 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -1,7 +1,10 @@ package orchestrator import ( + "context" "testing" + + "github.com/kandev/kandev/internal/task/models" ) func TestResolveIntent(t *testing.T) { @@ -111,3 +114,110 @@ func TestResolveIntent(t *testing.T) { }) } } + +// --- launchRestoreWorkspace --- + +func TestLaunchRestoreWorkspace_MissingSessionID(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + _, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{ + TaskID: "task1", + Intent: IntentRestoreWorkspace, + }) + if err == nil { + t.Fatal("expected error when session_id is empty") + } +} + +func TestLaunchRestoreWorkspace_SessionNotFound(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + _, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{ + TaskID: "task1", + Intent: IntentRestoreWorkspace, + SessionID: "nonexistent", + }) + if err == nil { + t.Fatal("expected error when session does not exist") + } +} + +func TestLaunchRestoreWorkspace_WrongTask(t *testing.T) { + repo := setupTestRepo(t) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + + seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateCompleted) + + _, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{ + TaskID: "task-wrong", + Intent: IntentRestoreWorkspace, + SessionID: "session1", + }) + if err == nil { + t.Fatal("expected error when session does not belong to task") + } +} + +func TestLaunchRestoreWorkspace_Success(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted) + + resp, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{ + TaskID: "task1", + Intent: IntentRestoreWorkspace, + SessionID: "session1", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !resp.Success { + t.Fatal("expected success=true") + } + if resp.SessionID != "session1" { + t.Errorf("expected session_id 'session1', got %q", resp.SessionID) + } + if resp.State != string(models.TaskSessionStateCompleted) { + t.Errorf("expected state %q, got %q", models.TaskSessionStateCompleted, resp.State) + } +} + +func TestLaunchRestoreWorkspace_IncludesWorktreeInfo(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) + + // Add worktree to the session + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt1", + SessionID: "session1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", + }); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + resp, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", + Intent: IntentRestoreWorkspace, + SessionID: "session1", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if resp.WorktreePath == nil || *resp.WorktreePath != "/tmp/worktrees/session1" { + t.Errorf("expected worktree_path '/tmp/worktrees/session1', got %v", resp.WorktreePath) + } + if resp.WorktreeBranch == nil || *resp.WorktreeBranch != "feature/test" { + t.Errorf("expected worktree_branch 'feature/test', got %v", resp.WorktreeBranch) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 057c45ef25..77563afbf0 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -752,6 +752,7 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st resp.IsAgentRunning = false resp.IsResumable = false resp.NeedsResume = false + resp.NeedsWorkspaceRestore = canRestoreWorkspace(&resp) return resp, nil } return s.validateResumeEligibility(session, resp), nil @@ -773,6 +774,7 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st resp.IsAgentRunning = false resp.IsResumable = false resp.NeedsResume = false + resp.NeedsWorkspaceRestore = canRestoreWorkspace(&resp) return resp, nil } @@ -822,6 +824,10 @@ func (s *Service) applyRemoteRuntimeStatus(ctx context.Context, sessionID string } // populateWorktreeInfo copies worktree path and branch into the response if present. +func canRestoreWorkspace(resp *dto.TaskSessionStatusResponse) bool { + return resp != nil && resp.WorktreePath != nil && *resp.WorktreePath != "" +} + func populateWorktreeInfo(session *models.TaskSession, resp *dto.TaskSessionStatusResponse) { if len(session.Worktrees) == 0 { return diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index f14fb4bf18..f402ecb3a8 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -10,6 +10,7 @@ import ( sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/orchestrator/dto" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/queue" "github.com/kandev/kandev/internal/orchestrator/scheduler" @@ -677,6 +678,103 @@ func TestEnsureSessionRunning_CreatedWithoutExecutionUsesResumePath(t *testing.T } } +// --- canRestoreWorkspace --- + +func TestCanRestoreWorkspace(t *testing.T) { + tests := []struct { + name string + resp *dto.TaskSessionStatusResponse + want bool + }{ + { + name: "nil response", + resp: nil, + want: false, + }, + { + name: "nil worktree path", + resp: &dto.TaskSessionStatusResponse{}, + want: false, + }, + { + name: "empty worktree path", + resp: &dto.TaskSessionStatusResponse{WorktreePath: strPtr("")}, + want: false, + }, + { + name: "valid worktree path", + resp: &dto.TaskSessionStatusResponse{WorktreePath: strPtr("/tmp/worktrees/session1")}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := canRestoreWorkspace(tc.resp); got != tc.want { + t.Errorf("canRestoreWorkspace() = %v, want %v", got, tc.want) + } + }) + } +} + +// --- GetTaskSessionStatus: NeedsWorkspaceRestore --- + +func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithWorktree(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted) + + // Add worktree to session + now := time.Now().UTC() + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt1", + SessionID: "session1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", + CreatedAt: now, + }); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + taskRepo := newMockTaskRepo() + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus returned error: %v", err) + } + if !resp.NeedsWorkspaceRestore { + t.Fatal("expected NeedsWorkspaceRestore=true for terminal session with worktree") + } + if resp.NeedsResume { + t.Fatal("expected NeedsResume=false for terminal session") + } +} + +func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithoutWorktree(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted) + + taskRepo := newMockTaskRepo() + agentMgr := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus returned error: %v", err) + } + if resp.NeedsWorkspaceRestore { + t.Fatal("expected NeedsWorkspaceRestore=false for terminal session without worktree") + } +} + // sessionUpdatingAgentManager wraps mockAgentManager to update session state // when StartAgentProcess is called, simulating the agent initialization flow. type sessionUpdatingAgentManager struct { diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index e702146bc5..98118d37d0 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -234,12 +234,36 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio } } - return &lifecycle.WorkspaceInfo{ + info := &lifecycle.WorkspaceInfo{ TaskID: taskID, SessionID: sessionID, WorkspacePath: workspacePath, AgentProfileID: session.AgentProfileID, AgentID: agentID, ACPSessionID: acpSessionID, - }, nil + } + + // Populate executor info for correct runtime selection and remote reconnection + running, err := s.executors.GetExecutorRunningBySessionID(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to get executor running for session", + zap.String("session_id", sessionID), + zap.Error(err)) + } else if running != nil { + info.RuntimeName = running.Runtime + info.AgentExecutionID = running.AgentExecutionID + } + if session.ExecutorID != "" { + exec, err := s.executors.GetExecutor(ctx, session.ExecutorID) + if err != nil { + s.logger.Warn("failed to get executor for session", + zap.String("session_id", sessionID), + zap.String("executor_id", session.ExecutorID), + zap.Error(err)) + } else if exec != nil { + info.ExecutorType = string(exec.Type) + } + } + + return info, nil } diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go new file mode 100644 index 0000000000..65b504a881 --- /dev/null +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -0,0 +1,210 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + AgentProfileID: "profile-1", + State: models.TaskSessionStateCompleted, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "auggie", + }, + Metadata: map[string]interface{}{ + "acp_session_id": "acp-123", + }, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Add a worktree to the session + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt1", + SessionID: "session-1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session-1", + WorktreeBranch: "feature/test", + CreatedAt: now, + }); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + + if info.TaskID != "task-123" { + t.Errorf("expected TaskID 'task-123', got %q", info.TaskID) + } + if info.SessionID != "session-1" { + t.Errorf("expected SessionID 'session-1', got %q", info.SessionID) + } + if info.WorkspacePath != "/tmp/worktrees/session-1" { + t.Errorf("expected WorkspacePath '/tmp/worktrees/session-1', got %q", info.WorkspacePath) + } + if info.AgentProfileID != "profile-1" { + t.Errorf("expected AgentProfileID 'profile-1', got %q", info.AgentProfileID) + } + if info.AgentID != "auggie" { + t.Errorf("expected AgentID 'auggie', got %q", info.AgentID) + } + if info.ACPSessionID != "acp-123" { + t.Errorf("expected ACPSessionID 'acp-123', got %q", info.ACPSessionID) + } +} + +func TestGetWorkspaceInfoForSession_InfersTaskID(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Pass empty taskID - should be inferred from the session + info, err := svc.GetWorkspaceInfoForSession(ctx, "", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + if info.TaskID != "task-123" { + t.Errorf("expected TaskID 'task-123' inferred from session, got %q", info.TaskID) + } +} + +func TestGetWorkspaceInfoForSession_ExecutorInfo(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + // Create executor + exec := &models.Executor{ + ID: "exec-1", + Name: "My Sprites Executor", + Type: models.ExecutorTypeSprites, + Status: "active", + CreatedAt: now, + UpdatedAt: now, + } + if err := repo.CreateExecutor(ctx, exec); err != nil { + t.Fatalf("failed to create executor: %v", err) + } + + // Create session with executor reference + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + ExecutorID: "exec-1", + State: models.TaskSessionStateCompleted, + AgentProfileSnapshot: map[string]interface{}{ + "agent_name": "auggie", + }, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Create executor running record + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er-1", + SessionID: "session-1", + TaskID: "task-123", + ExecutorID: "exec-1", + Runtime: "sprites", + AgentExecutionID: "agent-exec-abc123", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to upsert executor running: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + + if info.ExecutorType != "sprites" { + t.Errorf("expected ExecutorType 'sprites', got %q", info.ExecutorType) + } + if info.RuntimeName != "sprites" { + t.Errorf("expected RuntimeName 'sprites', got %q", info.RuntimeName) + } + if info.AgentExecutionID != "agent-exec-abc123" { + t.Errorf("expected AgentExecutionID 'agent-exec-abc123', got %q", info.AgentExecutionID) + } +} + +func TestGetWorkspaceInfoForSession_NoExecutorRunning(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + setupTestTask(t, repo) + now := time.Now().UTC() + + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-123", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, + } + if err := repo.CreateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // No executor running record - should still succeed with empty executor fields + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err) + } + if info.RuntimeName != "" { + t.Errorf("expected empty RuntimeName, got %q", info.RuntimeName) + } + if info.AgentExecutionID != "" { + t.Errorf("expected empty AgentExecutionID, got %q", info.AgentExecutionID) + } + if info.ExecutorType != "" { + t.Errorf("expected empty ExecutorType, got %q", info.ExecutorType) + } +} + +func TestGetWorkspaceInfoForSession_SessionNotFound(t *testing.T) { + svc, _, _ := createTestService(t) + ctx := context.Background() + + _, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent session") + } +} diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index ea00f89af9..af2494eb24 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { launchSession } from "@/lib/services/session-launch-service"; -import { buildResumeRequest } from "@/lib/services/session-launch-helpers"; +import { buildResumeRequest, buildRestoreWorkspaceRequest } from "@/lib/services/session-launch-helpers"; import { useAppStore } from "@/components/state-provider"; import type { TaskSessionState } from "@/lib/types/http"; @@ -13,6 +13,7 @@ export type SessionStatus = { is_agent_running: boolean; is_resumable: boolean; needs_resume: boolean; + needs_workspace_restore?: boolean; resume_reason?: string; acp_session_id?: string; worktree_path?: string; @@ -84,6 +85,31 @@ function applyResumeResponse( return false; } +/** Launch a session via a request builder and apply the response. */ +async function resumeViaLaunch( + taskId: string, + sessionId: string, + session: SessionLike, + setters: ResumeStateSetter, + buildRequest: (taskId: string, sessionId: string) => { request: import("@/lib/services/session-launch-service").LaunchSessionRequest }, +): Promise { + setters.setResumptionState("resuming"); + const { request } = buildRequest(taskId, sessionId); + const launchResp = await launchSession(request); + applyResumeResponse( + { + success: launchResp.success, + state: launchResp.state, + worktree_path: launchResp.worktree_path, + worktree_branch: launchResp.worktree_branch, + }, + taskId, + sessionId, + session, + setters, + ); +} + type CheckAndResumeParams = { taskId: string; sessionId: string; @@ -92,6 +118,27 @@ type CheckAndResumeParams = { setters: ResumeStateSetter; }; +/** Apply session status fields to local state. */ +function applyStatusToState( + status: SessionStatus, + taskId: string, + sessionId: string, + session: SessionLike, + setters: ResumeStateSetter, +): void { + setters.setWorktreePath(status.worktree_path ?? null); + setters.setWorktreeBranch(status.worktree_branch ?? null); + if (status.state) { + setters.setTaskSession({ + id: sessionId, + task_id: taskId, + state: status.state as TaskSessionState, + started_at: session?.started_at ?? "", + updated_at: session?.updated_at ?? "", + }); + } +} + async function checkAndResume({ taskId, sessionId, @@ -114,32 +161,13 @@ async function checkAndResume({ setters.setError(status.error); return; } - setters.setWorktreePath(status.worktree_path ?? null); - setters.setWorktreeBranch(status.worktree_branch ?? null); - if (status.state) { - setters.setTaskSession({ - id: sessionId, - task_id: taskId, - state: status.state as TaskSessionState, - started_at: session?.started_at ?? "", - updated_at: session?.updated_at ?? "", - }); - } + applyStatusToState(status, taskId, sessionId, session, setters); if (status.is_agent_running) { setters.setResumptionState("running"); - return; - } - if (status.needs_resume && status.is_resumable) { - setters.setResumptionState("resuming"); - const { request } = buildResumeRequest(taskId, sessionId); - const launchResp = await launchSession(request); - const resumeResp: ResumeResponse = { - success: launchResp.success, - state: launchResp.state, - worktree_path: launchResp.worktree_path, - worktree_branch: launchResp.worktree_branch, - }; - applyResumeResponse(resumeResp, taskId, sessionId, session, setters); + } else if (status.needs_resume && status.is_resumable) { + await resumeViaLaunch(taskId, sessionId, session, setters, buildResumeRequest); + } else if (status.needs_workspace_restore) { + await resumeViaLaunch(taskId, sessionId, session, setters, buildRestoreWorkspaceRequest); } else { setters.setResumptionState("idle"); } diff --git a/apps/web/lib/services/session-launch-helpers.ts b/apps/web/lib/services/session-launch-helpers.ts index 2802bc488b..5dcf7ebefd 100644 --- a/apps/web/lib/services/session-launch-helpers.ts +++ b/apps/web/lib/services/session-launch-helpers.ts @@ -90,6 +90,17 @@ export function buildResumeRequest(taskId: string, sessionId: string): BuildResu }; } +export function buildRestoreWorkspaceRequest(taskId: string, sessionId: string): BuildResult { + return { + request: { + task_id: taskId, + intent: "restore_workspace", + session_id: sessionId, + }, + layout: "default", + }; +} + export function buildWorkflowStepRequest( taskId: string, sessionId: string, diff --git a/apps/web/lib/services/session-launch-service.ts b/apps/web/lib/services/session-launch-service.ts index 23e15c11cb..1de93b1b7b 100644 --- a/apps/web/lib/services/session-launch-service.ts +++ b/apps/web/lib/services/session-launch-service.ts @@ -1,6 +1,6 @@ import { getWebSocketClient } from "@/lib/ws/connection"; -export type SessionIntent = "prepare" | "start" | "start_created" | "resume" | "workflow_step"; +export type SessionIntent = "prepare" | "start" | "start_created" | "resume" | "workflow_step" | "restore_workspace"; export type LaunchSessionRequest = { task_id: string;