feat: improve session restoration for complete/failed/cancelled (#302)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user