feat: improve session restoration for complete/failed/cancelled (#302)

This commit is contained in:
Carlos Florêncio
2026-02-27 16:00:11 +00:00
committed by GitHub
parent 31dc3d148b
commit 50b3543bf1
22 changed files with 667 additions and 125 deletions
+19 -13
View File
@@ -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 {
@@ -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
@@ -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",
@@ -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)
@@ -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,
@@ -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
@@ -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
}
@@ -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"`
@@ -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 ---
@@ -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
@@ -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) {
@@ -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 {
@@ -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
@@ -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{
@@ -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)
}
}
@@ -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
@@ -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 {
@@ -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
}
@@ -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")
}
}
@@ -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<void> {
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");
}
@@ -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,
@@ -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;