diff --git a/.gitignore b/.gitignore index aa8c186f22..7adcfd6c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ normalized-*.jsonl # Serena MCP .serena/* +.gitnexus diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index eced497c3a..38ed84b4a8 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -87,12 +87,16 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch SetupScript: req.SetupScript, // Worktree configuration for concurrent agent execution UseWorktree: req.UseWorktree, + WorktreeID: req.WorktreeID, RepositoryID: req.RepositoryID, RepositoryPath: req.RepositoryPath, BaseBranch: req.BaseBranch, CheckoutBranch: req.CheckoutBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, + // Task directory mode + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, } // Create the agentctl execution (does NOT start agent process) diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 0da1704728..6a0daa0bf7 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -81,9 +81,6 @@ func appendSessionStateMessage(sessionID string, session *models.TaskSession, re if session.ReviewStatus != nil && *session.ReviewStatus != "" { payload["review_status"] = *session.ReviewStatus } - if session.WorkflowStepID != nil && *session.WorkflowStepID != "" { - payload["workflow_step_id"] = *session.WorkflowStepID - } if session.Metadata != nil { payload["session_metadata"] = session.Metadata } @@ -580,7 +577,7 @@ func registerMCPAndDebugRoutes( ) { mcpHandlers := mcphandlers.NewHandlers( p.taskSvc, wfCtrl, - clarificationStore, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, p.log, + clarificationStore, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, p.orchestratorSvc, p.log, ) // Wire config-mode dependencies for agent-native configuration mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 5a0f4ed657..18c9a42734 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -41,18 +41,19 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories workflowSvc := workflowservice.NewService(repos.Workflow, log) taskSvc := taskservice.NewService( taskservice.Repos{ - Workspaces: repos.Task, - Tasks: repos.Task, - TaskRepos: repos.Task, - Workflows: repos.Task, - Messages: repos.Task, - Turns: repos.Task, - Sessions: repos.Task, - GitSnapshots: repos.Task, - RepoEntities: repos.Task, - Executors: repos.Task, - Environments: repos.Task, - Reviews: repos.Task, + Workspaces: repos.Task, + Tasks: repos.Task, + TaskRepos: repos.Task, + Workflows: repos.Task, + Messages: repos.Task, + Turns: repos.Task, + Sessions: repos.Task, + GitSnapshots: repos.Task, + RepoEntities: repos.Task, + Executors: repos.Task, + Environments: repos.Task, + TaskEnvironments: repos.Task, + Reviews: repos.Task, }, eventBus, log, diff --git a/apps/backend/config/utilityagents/summarize-session.md b/apps/backend/config/utilityagents/summarize-session.md new file mode 100644 index 0000000000..6996bb2eea --- /dev/null +++ b/apps/backend/config/utilityagents/summarize-session.md @@ -0,0 +1,15 @@ +Summarize the following conversation between a user and a coding assistant. +Focus on what was accomplished, key decisions made, and any remaining work. + +## Conversation: +{{ConversationHistory}} + +## Instructions: +1. Start with a one-sentence overview of the session's goal +2. List key accomplishments (files changed, features added, bugs fixed) +3. Note any important decisions or trade-offs discussed +4. Mention any remaining work or known issues +5. Keep the summary concise but comprehensive (2-4 paragraphs) + +## Output Format: +Return ONLY the summary text, no explanations or markdown headers. diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go index c64cdb5596..c7a457e921 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer.go @@ -39,6 +39,9 @@ type EnvPrepareRequest struct { WorktreeBranchPrefix string PullBeforeWorktree bool + TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123") + RepoName string // Repository slug used with TaskDirName to locate checkouts + Env map[string]string } diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go index b7c9b7d528..597cc3b746 100644 --- a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go +++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go @@ -88,6 +88,8 @@ func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, WorktreeID: req.WorktreeID, + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, } if syncStep != nil { createReq.OnSyncProgress = func(event worktree.SyncProgressEvent) { diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go index 971532e74f..43b8a7ec6e 100644 --- a/apps/backend/internal/agent/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/lifecycle/manager_launch.go @@ -319,11 +319,14 @@ func (m *Manager) runEnvironmentPreparer( RepositoryPath: req.RepositoryPath, RepositoryID: req.RepositoryID, UseWorktree: req.UseWorktree, + WorktreeID: req.WorktreeID, SetupScript: req.SetupScript, BaseBranch: req.BaseBranch, CheckoutBranch: req.CheckoutBranch, WorktreeBranchPrefix: req.WorktreeBranchPrefix, PullBeforeWorktree: req.PullBeforeWorktree, + TaskDirName: req.TaskDirName, + RepoName: req.RepoName, Env: req.Env, } diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 2b24fe4c0b..b7ac7f4048 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -267,12 +267,17 @@ type LaunchRequest struct { // Worktree configuration UseWorktree bool // Whether to use a Git worktree for isolation + WorktreeID string // Existing worktree ID to reuse (skip creation if set) RepositoryID string // Repository ID for worktree tracking RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) WorktreeBranchPrefix string // Branch prefix for worktree branches PullBeforeWorktree bool // Whether to pull from remote before creating the worktree + + // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ + TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") + RepoName string // Repository name used as subdirectory inside the task directory } // MessageAttachment represents an image or file attachment for agent prompts. diff --git a/apps/backend/internal/agentctl/server/mcp/handlers.go b/apps/backend/internal/agentctl/server/mcp/handlers.go index a0d6c7819a..7d725e7f10 100644 --- a/apps/backend/internal/agentctl/server/mcp/handlers.go +++ b/apps/backend/internal/agentctl/server/mcp/handlers.go @@ -76,30 +76,35 @@ func (s *Server) listTasksHandler() server.ToolHandlerFunc { func (s *Server) createTaskHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - workspaceID, err := req.RequireString("workspace_id") - if err != nil { - return mcp.NewToolResultError("workspace_id is required"), nil - } - workflowID, err := req.RequireString("workflow_id") - if err != nil { - return mcp.NewToolResultError("workflow_id is required"), nil - } - workflowStepID, err := req.RequireString("workflow_step_id") - if err != nil { - return mcp.NewToolResultError("workflow_step_id is required"), nil - } title, err := req.RequireString("title") if err != nil { return mcp.NewToolResultError("title is required"), nil } - description := req.GetString("description", "") + + parentID := req.GetString("parent_id", "") + if parentID == "self" { + if s.taskID == "" { + return mcp.NewToolResultError("cannot use 'self' as parent_id: no current task context"), nil + } + parentID = s.taskID + } + workspaceID := req.GetString("workspace_id", "") + workflowID := req.GetString("workflow_id", "") + workflowStepID := req.GetString("workflow_step_id", "") + + if parentID == "" && (workspaceID == "" || workflowID == "") { + return mcp.NewToolResultError("workspace_id and workflow_id are required when creating a top-level task (no parent_id)"), nil + } payload := map[string]string{ - "workspace_id": workspaceID, - "workflow_id": workflowID, - "workflow_step_id": workflowStepID, - "title": title, - "description": description, + "parent_id": parentID, + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": title, + "description": req.GetString("description", ""), + "agent_profile_id": req.GetString("agent_profile_id", ""), + "executor_profile_id": req.GetString("executor_profile_id", ""), } var result map[string]interface{} if err := s.backend.RequestPayload(ctx, ws.ActionMCPCreateTask, payload, &result); err != nil { diff --git a/apps/backend/internal/agentctl/server/mcp/handlers_test.go b/apps/backend/internal/agentctl/server/mcp/handlers_test.go new file mode 100644 index 0000000000..156540d164 --- /dev/null +++ b/apps/backend/internal/agentctl/server/mcp/handlers_test.go @@ -0,0 +1,132 @@ +package mcp + +import ( + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTaskModeServer(t *testing.T, backend BackendClient, taskID string) *Server { + t.Helper() + log := newTestLogger(t) + return New(backend, "test-session", taskID, 10005, log, "", false, ModeTask) +} + +func TestCreateTask_ToolSchema_HasParentID(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + toolsMap := s.mcpServer.ListTools() + tool, ok := toolsMap["create_task"] + require.True(t, ok, "create_task tool not registered") + + schema, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(schema, &parsed)) + + props, ok := parsed["properties"].(map[string]interface{}) + require.True(t, ok, "schema should have properties") + assert.Contains(t, props, "parent_id", "create_task schema must expose parent_id") + assert.Contains(t, props, "title") + assert.Contains(t, props, "workspace_id") + assert.Contains(t, props, "workflow_id") + + // parent_id, workspace_id, workflow_id, workflow_step_id should NOT be required + required, _ := parsed["required"].([]interface{}) + requiredSet := make(map[string]bool) + for _, r := range required { + requiredSet[r.(string)] = true + } + assert.True(t, requiredSet["title"], "title should be required") + assert.False(t, requiredSet["parent_id"], "parent_id should not be required") + assert.False(t, requiredSet["workspace_id"], "workspace_id should not be required") + assert.False(t, requiredSet["workflow_id"], "workflow_id should not be required") +} + +func TestCreateTask_SelfResolvesToTaskID(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "subtask-1", "parent_id": "task-current"}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task", map[string]interface{}{ + "title": "Write tests", + "parent_id": "self", + }) + + assert.False(t, result.IsError) + assert.Equal(t, ws.ActionMCPCreateTask, backend.lastAction) + + payload, ok := backend.lastPayload.(map[string]string) + require.True(t, ok) + assert.Equal(t, "task-current", payload["parent_id"], "self should resolve to current task ID") + assert.Equal(t, "Write tests", payload["title"]) +} + +func TestCreateTask_SelfWithNoTaskContext_ReturnsError(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "") + + result := callTool(t, s, "create_task", map[string]interface{}{ + "title": "Write tests", + "parent_id": "self", + }) + + assert.True(t, result.IsError) +} + +func TestCreateTask_ExplicitParentID(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "subtask-1", "parent_id": "task-abc"}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task", map[string]interface{}{ + "title": "Fix bug", + "parent_id": "task-abc", + }) + + assert.False(t, result.IsError) + + payload, ok := backend.lastPayload.(map[string]string) + require.True(t, ok) + assert.Equal(t, "task-abc", payload["parent_id"]) +} + +func TestCreateTask_NoParentID_RequiresWorkspaceAndWorkflow(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + // No parent_id and no workspace/workflow -> error + result := callTool(t, s, "create_task", map[string]interface{}{ + "title": "Standalone task", + }) + + assert.True(t, result.IsError) +} + +func TestCreateTask_NoParentID_WithIDs_CreatesTopLevelTask(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{"id": "task-new", "title": "Standalone"}, + } + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "create_task", map[string]interface{}{ + "title": "Standalone", + "workspace_id": "ws-1", + "workflow_id": "wf-1", + }) + + assert.False(t, result.IsError) + + payload, ok := backend.lastPayload.(map[string]string) + require.True(t, ok) + assert.Equal(t, "", payload["parent_id"]) + assert.Equal(t, "ws-1", payload["workspace_id"]) + assert.Equal(t, "wf-1", payload["workflow_id"]) +} diff --git a/apps/backend/internal/agentctl/server/mcp/server.go b/apps/backend/internal/agentctl/server/mcp/server.go index e0976315df..ee99a2484c 100644 --- a/apps/backend/internal/agentctl/server/mcp/server.go +++ b/apps/backend/internal/agentctl/server/mcp/server.go @@ -242,7 +242,7 @@ func (s *Server) registerTools() { } default: // ModeTask s.registerKanbanTools() - count += 6 + count += 8 if !s.disableAskQuestion { s.registerInteractionTools() count++ @@ -291,15 +291,32 @@ func (s *Server) registerKanbanTools() { ) s.mcpServer.AddTool( mcp.NewTool("create_task", - mcp.WithDescription("Create a new task in a workflow."), - mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")), - mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")), - mcp.WithString("workflow_step_id", mcp.Required(), mcp.Description("The workflow step ID")), + mcp.WithDescription("Create a new task or subtask and auto-start an agent on it. For subtasks (parent_id='self'), the executor profile and agent profile are automatically inherited from the parent session — no need to ask. For top-level tasks, use ask_user_question first if you do not already know which executor profile and agent profile the user wants to use. IMPORTANT: 'description' is the initial prompt the sub-agent receives — it is the ONLY context the sub-agent has to start working. Always provide a detailed description for subtasks."), + mcp.WithString("parent_id", mcp.Description("Parent task ID for subtasks. Use 'self' to create a subtask of your current task. Omit to create a top-level task.")), + mcp.WithString("workspace_id", mcp.Description("The workspace ID (required for top-level tasks, inherited from parent for subtasks)")), + mcp.WithString("workflow_id", mcp.Description("The workflow ID (required for top-level tasks, inherited from parent for subtasks)")), + mcp.WithString("workflow_step_id", mcp.Description("The workflow step ID (optional, auto-resolved if omitted)")), mcp.WithString("title", mcp.Required(), mcp.Description("The task title")), - mcp.WithString("description", mcp.Description("The task description")), + mcp.WithString("description", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. REQUIRED for subtasks: without a description the sub-agent starts with no context and cannot do useful work. Be specific and detailed.")), + mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")), + mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), ), s.wrapHandler("create_task", s.createTaskHandler()), ) + s.mcpServer.AddTool( + mcp.NewToolWithRawSchema("list_agents", + "List all configured agents with their profiles. Use this to find available agent_profile_ids for create_task.", + json.RawMessage(`{"type":"object","properties":{}}`), + ), + s.wrapHandler("list_agents", s.listAgentsHandler()), + ) + s.mcpServer.AddTool( + mcp.NewTool("list_executor_profiles", + mcp.WithDescription("List all profiles for an executor. Use this to find available executor_profile_ids for create_task. Standard executor IDs: exec-local (standalone process), exec-worktree (git worktree), exec-local-docker (Docker container), exec-sprites (cloud)."), + mcp.WithString("executor_id", mcp.Required(), mcp.Description("The executor ID (e.g. exec-local, exec-worktree, exec-local-docker, exec-sprites)")), + ), + s.wrapHandler("list_executor_profiles", s.listExecutorProfilesHandler()), + ) s.mcpServer.AddTool( mcp.NewTool("update_task", mcp.WithDescription("Update an existing task."), diff --git a/apps/backend/internal/agentctl/server/mcp/server_test.go b/apps/backend/internal/agentctl/server/mcp/server_test.go index 6b6356dd9f..15627fb11e 100644 --- a/apps/backend/internal/agentctl/server/mcp/server_test.go +++ b/apps/backend/internal/agentctl/server/mcp/server_test.go @@ -52,13 +52,16 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) { // Task mode should have interaction tools assert.Contains(t, tools, "ask_user_question") - // Task mode should NOT have config tools + // Task mode should have profile listing tools (needed for create_task) + assert.Contains(t, tools, "list_agents") + assert.Contains(t, tools, "list_executor_profiles") + + // Task mode should NOT have config/mutation tools assert.NotContains(t, tools, "create_workflow") assert.NotContains(t, tools, "update_workflow") assert.NotContains(t, tools, "delete_workflow") assert.NotContains(t, tools, "create_workflow_step") assert.NotContains(t, tools, "update_workflow_step") - assert.NotContains(t, tools, "list_agents") assert.NotContains(t, tools, "update_agent") assert.NotContains(t, tools, "create_agent_profile") assert.NotContains(t, tools, "delete_agent_profile") @@ -70,7 +73,6 @@ func TestServerModeTask_RegistersCorrectTools(t *testing.T) { assert.NotContains(t, tools, "delete_task") assert.NotContains(t, tools, "archive_task") assert.NotContains(t, tools, "list_executors") - assert.NotContains(t, tools, "list_executor_profiles") assert.NotContains(t, tools, "create_executor_profile") assert.NotContains(t, tools, "update_executor_profile") assert.NotContains(t, tools, "delete_executor_profile") @@ -191,8 +193,8 @@ func TestServerModeTask_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask) tools := getRegisteredToolNames(s) - // 6 kanban + 1 interaction + 4 plan = 11 - assert.Equal(t, 11, len(tools)) + // 8 kanban + 1 interaction + 4 plan = 13 + assert.Equal(t, 13, len(tools)) } func TestServerModeConfig_ToolCount(t *testing.T) { diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 9a8091a8f2..9009dfe62f 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -93,7 +93,8 @@ func NewTestServer(t *testing.T) *TestServer { Workspaces: taskRepo, Tasks: taskRepo, TaskRepos: taskRepo, Workflows: taskRepo, Messages: taskRepo, Turns: taskRepo, Sessions: taskRepo, GitSnapshots: taskRepo, RepoEntities: taskRepo, - Executors: taskRepo, Environments: taskRepo, Reviews: taskRepo, + Executors: taskRepo, Environments: taskRepo, TaskEnvironments: taskRepo, + Reviews: taskRepo, }, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) taskSvc.SetWorkflowStepCreator(workflowSvc) diff --git a/apps/backend/internal/integration/mcp_integration_test.go b/apps/backend/internal/integration/mcp_integration_test.go new file mode 100644 index 0000000000..27889115bc --- /dev/null +++ b/apps/backend/internal/integration/mcp_integration_test.go @@ -0,0 +1,200 @@ +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// setupMCPTestServer creates a test server with MCP handlers registered +// and returns the server along with a parent task's IDs for subtask tests. +func setupMCPTestServer(t *testing.T) (*TestServer, string, string, string, string) { + t.Helper() + + ts := NewTestServer(t) + + // Register MCP handlers on the gateway dispatcher + mcpH := mcphandlers.NewHandlers(ts.TaskSvc, nil, nil, nil, nil, nil, nil, nil, nil, ts.Logger) + mcpH.RegisterHandlers(ts.Gateway.Dispatcher) + + client := NewWSClient(t, ts.Server.URL) + t.Cleanup(func() { client.Close() }) + + workspaceID := createWorkspace(t, client) + + // Create workflow + workflowResp, err := client.SendRequest("wf-1", ws.ActionWorkflowCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "name": "MCP Test Workflow", + "workflow_template_id": "simple", + }) + require.NoError(t, err) + var wfPayload map[string]interface{} + require.NoError(t, workflowResp.ParsePayload(&wfPayload)) + workflowID := wfPayload["id"].(string) + + // Get first workflow step + stepResp, err := client.SendRequest("steps-1", ws.ActionWorkflowStepList, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + var stepPayload map[string]interface{} + require.NoError(t, stepResp.ParsePayload(&stepPayload)) + steps := stepPayload["steps"].([]interface{}) + workflowStepID := steps[0].(map[string]interface{})["id"].(string) + + // Create parent task + taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Parent Task", + }) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, taskResp.Type) + var taskPayload map[string]interface{} + require.NoError(t, taskResp.ParsePayload(&taskPayload)) + parentTaskID := taskPayload["id"].(string) + + return ts, parentTaskID, workspaceID, workflowID, workflowStepID +} + +func TestMCPCreateTask_SubtaskInheritsFromParent(t *testing.T) { + ts, parentTaskID, workspaceID, workflowID, _ := setupMCPTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + // Create subtask with title, description, and parent_id — workspace/workflow/step should be inherited + resp, err := client.SendRequest("subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{ + "parent_id": parentTaskID, + "title": "Subtask via MCP", + "description": "Implement the subtask feature", + }) + require.NoError(t, err) + + if resp.Type == ws.MessageTypeError { + var errPayload ws.ErrorPayload + require.NoError(t, resp.ParsePayload(&errPayload)) + t.Fatalf("expected response but got error: %s", errPayload.Message) + } + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.NotEmpty(t, payload["id"]) + assert.Equal(t, "Subtask via MCP", payload["title"]) + assert.Equal(t, parentTaskID, payload["parent_id"]) + assert.Equal(t, workspaceID, payload["workspace_id"]) + assert.Equal(t, workflowID, payload["workflow_id"]) +} + +func TestMCPCreateTask_SubtaskVisibleInListTasks(t *testing.T) { + ts, parentTaskID, _, _, _ := setupMCPTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + // Get parent's workflow_id for listing + getResp, err := client.SendRequest("get-parent", ws.ActionTaskGet, map[string]interface{}{ + "id": parentTaskID, + }) + require.NoError(t, err) + var parentPayload map[string]interface{} + require.NoError(t, getResp.ParsePayload(&parentPayload)) + workflowID := parentPayload["workflow_id"].(string) + + // Create subtask + resp, err := client.SendRequest("subtask-1", ws.ActionMCPCreateTask, map[string]interface{}{ + "parent_id": parentTaskID, + "title": "Visible Subtask", + "description": "Implement the visible subtask feature", + }) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + // List tasks — should contain both parent and subtask + listResp, err := client.SendRequest("list-1", ws.ActionMCPListTasks, map[string]interface{}{ + "workflow_id": workflowID, + }) + require.NoError(t, err) + var listPayload map[string]interface{} + require.NoError(t, listResp.ParsePayload(&listPayload)) + + tasks := listPayload["tasks"].([]interface{}) + assert.Len(t, tasks, 2, "should have parent + subtask") + + // Find the subtask and verify parent_id + var found bool + for _, raw := range tasks { + task := raw.(map[string]interface{}) + if task["title"] == "Visible Subtask" { + assert.Equal(t, parentTaskID, task["parent_id"]) + found = true + } + } + assert.True(t, found, "subtask should be visible in list_tasks") +} + +func TestMCPCreateTask_TopLevelWithoutParent(t *testing.T) { + ts, _, workspaceID, workflowID, workflowStepID := setupMCPTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + // Create top-level task (no parent_id, all IDs provided) + resp, err := client.SendRequest("top-1", ws.ActionMCPCreateTask, map[string]interface{}{ + "workspace_id": workspaceID, + "workflow_id": workflowID, + "workflow_step_id": workflowStepID, + "title": "Top Level Task", + }) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + var payload map[string]interface{} + require.NoError(t, resp.ParsePayload(&payload)) + + assert.NotEmpty(t, payload["id"]) + assert.Equal(t, "Top Level Task", payload["title"]) + // parent_id should be empty (omitempty means absent from JSON) + _, hasParent := payload["parent_id"] + assert.False(t, hasParent, "top-level task should not have parent_id") +} + +func TestMCPCreateTask_NoParentNoIDs_ReturnsError(t *testing.T) { + ts, _, _, _, _ := setupMCPTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + // No parent_id and no workspace/workflow -> should fail + resp, err := client.SendRequest("fail-1", ws.ActionMCPCreateTask, map[string]interface{}{ + "title": "Orphan Task", + }) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, resp.Type) +} + +func TestMCPCreateTask_InvalidParentID_ReturnsError(t *testing.T) { + ts, _, _, _, _ := setupMCPTestServer(t) + defer ts.Close() + + client := NewWSClient(t, ts.Server.URL) + defer client.Close() + + resp, err := client.SendRequest("bad-parent", ws.ActionMCPCreateTask, map[string]interface{}{ + "parent_id": "nonexistent-task-id", + "title": "Bad Parent", + }) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, resp.Type) +} diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index 00975d2f00..e61b1fa783 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -264,7 +264,8 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { Workspaces: taskRepo, Tasks: taskRepo, TaskRepos: taskRepo, Workflows: taskRepo, Messages: taskRepo, Turns: taskRepo, Sessions: taskRepo, GitSnapshots: taskRepo, RepoEntities: taskRepo, - Executors: taskRepo, Environments: taskRepo, Reviews: taskRepo, + Executors: taskRepo, Environments: taskRepo, TaskEnvironments: taskRepo, + Reviews: taskRepo, }, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) taskSvc.SetWorkflowStepCreator(workflowSvc) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 1375b9cdea..5fd7deaad1 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -12,9 +12,11 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/clarification" + "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" @@ -53,6 +55,11 @@ type EventBus interface { Publish(ctx context.Context, topic string, event *bus.Event) error } +// SessionLauncher provides session launch capability for auto-starting tasks. +type SessionLauncher interface { + LaunchSession(ctx context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) +} + // Handlers provides MCP WebSocket handlers. type Handlers struct { taskSvc *service.Service @@ -63,6 +70,7 @@ type Handlers struct { taskRepo TaskRepository eventBus EventBus planService *service.PlanService + sessionLauncher SessionLauncher logger *logger.Logger // Config-mode dependencies (optional, set via SetConfigDeps) @@ -81,6 +89,7 @@ func NewHandlers( taskRepo TaskRepository, eventBus EventBus, planService *service.PlanService, + sessionLauncher SessionLauncher, log *logger.Logger, ) *Handlers { return &Handlers{ @@ -92,6 +101,7 @@ func NewHandlers( taskRepo: taskRepo, eventBus: eventBus, planService: planService, + sessionLauncher: sessionLauncher, logger: log.WithFields(zap.String("component", "mcp-handlers")), } } @@ -145,6 +155,11 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPDeleteAgentProfile, h.handleDeleteAgentProfile) count += 6 } + // list_executor_profiles is always available (read-only, used in task mode for create_task) + if h.taskSvc != nil { + d.RegisterFunc(ws.ActionMCPListExecutorProfiles, h.handleListExecutorProfiles) + count++ + } if h.mcpConfigSvc != nil { d.RegisterFunc(ws.ActionMCPGetMcpConfig, h.handleGetMcpConfig) d.RegisterFunc(ws.ActionMCPUpdateMcpConfig, h.handleUpdateMcpConfig) @@ -157,14 +172,12 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPUpdateTaskState, h.handleUpdateTaskState) count += 4 - // Executor config handlers (gated on config-mode via workflowSvc) + // Executor mutation handlers (config-mode only) if h.workflowSvc != nil { - d.RegisterFunc(ws.ActionMCPListExecutors, h.handleListExecutors) - d.RegisterFunc(ws.ActionMCPListExecutorProfiles, h.handleListExecutorProfiles) d.RegisterFunc(ws.ActionMCPCreateExecutorProfile, h.handleCreateExecutorProfile) d.RegisterFunc(ws.ActionMCPUpdateExecutorProfile, h.handleUpdateExecutorProfile) d.RegisterFunc(ws.ActionMCPDeleteExecutorProfile, h.handleDeleteExecutorProfile) - count += 5 + count += 3 } } @@ -275,47 +288,154 @@ func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Me }) } -// handleCreateTask creates a new task. +// handleCreateTask creates a new task and auto-starts an agent session. func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { // Use local struct with JSON tags since dto.CreateTaskRequest lacks them var req struct { - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description"` + ParentID string `json:"parent_id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` } if err := json.Unmarshal(msg.Payload, &req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } + if req.Title == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) + } + if req.ParentID != "" && req.Description == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "description is required for subtasks: it is the sub-agent's initial prompt and the only context it receives to start working", nil) + } + + // Inherit workspace/workflow/repositories from parent when not explicitly provided. + // WorkflowStepID is intentionally NOT inherited — the service layer resolves + // the start step so subtasks always begin at the first workflow step, not the + // parent's current (potentially advanced) step. + var inheritedRepos []service.TaskRepositoryInput + if req.ParentID != "" { + parent, err := h.taskSvc.GetTask(ctx, req.ParentID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "invalid parent_id: "+err.Error(), nil) + } + if req.WorkspaceID == "" { + req.WorkspaceID = parent.WorkspaceID + } + if req.WorkflowID == "" { + req.WorkflowID = parent.WorkflowID + } + for _, r := range parent.Repositories { + inheritedRepos = append(inheritedRepos, service.TaskRepositoryInput{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + CheckoutBranch: r.CheckoutBranch, + }) + } + } + if req.WorkspaceID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil) } if req.WorkflowID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) } - if req.WorkflowStepID == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil) - } - if req.Title == "" { - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) - } task, err := h.taskSvc.CreateTask(ctx, &service.CreateTaskRequest{ + ParentID: req.ParentID, WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, WorkflowStepID: req.WorkflowStepID, Title: req.Title, Description: req.Description, + Repositories: inheritedRepos, }) if err != nil { h.logger.Error("failed to create task", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil) } + // Auto-start agent session asynchronously + h.autoStartTask(task, req.AgentProfileID, req.ExecutorProfileID) + return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task)) } +// autoStartTask launches an agent session for a newly created task in the background. +// It resolves the agent profile: explicit > parent's session > workspace default. +// It resolves the executor: explicit executor_profile_id > parent's executor_profile_id > +// parent's executor_id (fallback for sessions that have no profile). +func (h *Handlers) autoStartTask(task *models.Task, agentProfileID, executorProfileID string) { + if h.sessionLauncher == nil { + return + } + + executorID := h.inheritFromParentSession(task.ParentID, &agentProfileID, &executorProfileID) + if agentProfileID == "" { + workspace, err := h.taskSvc.GetWorkspace(context.Background(), task.WorkspaceID) + if err == nil && workspace.DefaultAgentProfileID != nil { + agentProfileID = *workspace.DefaultAgentProfileID + } + } + + if agentProfileID == "" { + h.logger.Warn("no agent profile available, skipping auto-start", + zap.String("task_id", task.ID)) + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout) + defer cancel() + + resp, err := h.sessionLauncher.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ + TaskID: task.ID, + Intent: orchestrator.IntentStart, + AgentProfileID: agentProfileID, + ExecutorID: executorID, + ExecutorProfileID: executorProfileID, + Prompt: task.Description, + }) + if err != nil { + h.logger.Error("failed to auto-start task", + zap.String("task_id", task.ID), zap.Error(err)) + return + } + h.logger.Info("auto-started agent for MCP-created task", + zap.String("task_id", task.ID), + zap.String("session_id", resp.SessionID)) + }() +} + +// inheritFromParentSession fills agentProfileID and executorProfileID from the parent +// task's primary session when not explicitly provided. It returns the parent's ExecutorID +// as a fallback for when the parent session has no executor profile (common for +// UI-created sessions). If ExecutorProfileID is resolved, ExecutorID is redundant +// since the profile already encodes the executor reference. +func (h *Handlers) inheritFromParentSession(parentID string, agentProfileID, executorProfileID *string) string { + if parentID == "" { + return "" + } + parent, err := h.taskSvc.GetPrimarySession(context.Background(), parentID) + if err != nil || parent == nil { + return "" + } + if *agentProfileID == "" { + *agentProfileID = parent.AgentProfileID + } + if *executorProfileID == "" { + *executorProfileID = parent.ExecutorProfileID + } + // Only return ExecutorID as fallback when no profile was resolved. + // An executor profile already encodes its executor reference. + if *executorProfileID == "" { + return parent.ExecutorID + } + return "" +} + // handleUpdateTask updates an existing task. func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { // Use local struct with JSON tags since dto.UpdateTaskRequest lacks them diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index f1ac8e8093..cd5e46f409 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -389,13 +389,10 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID "agent_profile_snapshot": session.AgentProfileSnapshot, "is_passthrough": session.IsPassthrough, } - // Include review_status and workflow_step_id if present to ensure frontend state consistency + // Include review_status if present to ensure frontend state consistency if session.ReviewStatus != nil { eventData["review_status"] = *session.ReviewStatus } - if session.WorkflowStepID != nil { - eventData["workflow_step_id"] = *session.WorkflowStepID - } // Include session metadata (e.g. plan_mode set by workflow events). // Key is "session_metadata" to avoid conflict with message-level "metadata". if len(session.Metadata) > 0 { @@ -405,8 +402,72 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID if suppressed, ok := s.suppressToast.LoadAndDelete(sessionID); ok && suppressed.(bool) { eventData["suppress_toast"] = true } + if session.TaskEnvironmentID != "" { + eventData["task_environment_id"] = session.TaskEnvironmentID + } _ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", eventData)) } + + // Auto-promote another session to primary when the current primary enters a terminal state + s.maybePromotePrimary(ctx, taskID, sessionID, nextState) +} + +// maybePromotePrimary promotes the next best active session to primary when the +// current primary session enters a terminal state (COMPLETED, FAILED, CANCELLED). +func (s *Service) maybePromotePrimary(ctx context.Context, taskID, sessionID string, newState models.TaskSessionState) { + if !isTerminalSessionState(newState) { + return + } + + // Check whether the stopped session is actually the primary + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return + } + var stoppedIsPrimary bool + for _, sess := range sessions { + if sess.ID == sessionID && sess.IsPrimary { + stoppedIsPrimary = true + break + } + } + if !stoppedIsPrimary { + return + } + + // Pick the best candidate: prefer RUNNING, then STARTING, then WAITING_FOR_INPUT + var candidate string + for _, sess := range sessions { + if sess.ID == sessionID { + continue + } + if sess.State == models.TaskSessionStateRunning { + candidate = sess.ID + break + } + if candidate == "" && isActiveSessionState(sess.State) { + candidate = sess.ID + } + } + if candidate != "" { + if err := s.SetPrimarySession(ctx, candidate); err != nil { + s.logger.Warn("failed to auto-promote primary session", + zap.String("task_id", taskID), + zap.String("candidate", candidate), + zap.Error(err)) + } else { + s.logger.Info("auto-promoted primary session", + zap.String("task_id", taskID), + zap.String("old_primary", sessionID), + zap.String("new_primary", candidate)) + } + } +} + +func isTerminalSessionState(s models.TaskSessionState) bool { + return s == models.TaskSessionStateCompleted || + s == models.TaskSessionStateFailed || + s == models.TaskSessionStateCancelled } func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string, preloadedSession ...*models.TaskSession) { diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index f8c7817be5..cdb918b34d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -296,12 +296,11 @@ func seedSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, w // Create session session := &models.TaskSession{ - ID: sessionID, - TaskID: taskID, - State: models.TaskSessionStateRunning, - WorkflowStepID: strPtr(workflowStepID), - StartedAt: now, - UpdatedAt: now, + ID: sessionID, + TaskID: taskID, + State: models.TaskSessionStateRunning, + StartedAt: now, + UpdatedAt: now, } if err := repo.CreateTaskSession(ctx, session); err != nil { t.Fatalf("failed to create task session: %v", err) @@ -447,9 +446,9 @@ func TestHandleAgentReadyGuards(t *testing.T) { svc.handleAgentReady(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - t.Fatalf("expected workflow step to remain step1, got %v", updated.WorkflowStepID) + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step1" { + t.Fatalf("expected workflow step to remain step1, got %q", updatedTask.WorkflowStepID) } status := svc.messageQueue.GetStatus(ctx, "s1") if !status.IsQueued { @@ -507,9 +506,9 @@ func TestHandleAgentReadyGuards(t *testing.T) { svc.handleAgentReady(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - t.Fatalf("expected workflow step to remain step1, got %v", updated.WorkflowStepID) + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step1" { + t.Fatalf("expected workflow step to remain step1, got %q", updatedTask.WorkflowStepID) } }) @@ -541,9 +540,9 @@ func TestHandleAgentReadyGuards(t *testing.T) { AgentExecutionID: "exec-stale", }) - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - t.Fatalf("expected workflow step to remain step1, got %v", updated.WorkflowStepID) + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step1" { + t.Fatalf("expected workflow step to remain step1, got %q", updatedTask.WorkflowStepID) } }) } @@ -645,17 +644,13 @@ func TestExecuteQueuedMessage_FiresOnTurnStart(t *testing.T) { svc.executeQueuedMessage("s1", queuedMsg) - // Verify on_turn_start moved the session from step1 to step2. - updated, err := repo.GetTaskSession(ctx, "s1") + // Verify on_turn_start moved the task from step1 to step2. + updatedTask, err := repo.GetTask(ctx, "t1") if err != nil { - t.Fatalf("failed to get session: %v", err) + t.Fatalf("failed to get task: %v", err) } - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step2" { - got := "" - if updated.WorkflowStepID != nil { - got = *updated.WorkflowStepID - } - t.Errorf("expected session workflow step to be 'step2', got %s", got) + if updatedTask.WorkflowStepID != "step2" { + t.Errorf("expected task workflow step to be 'step2', got %q", updatedTask.WorkflowStepID) } } @@ -703,17 +698,13 @@ func TestExecuteQueuedMessage_NoOnTurnStart_StepUnchanged(t *testing.T) { svc.executeQueuedMessage("s1", queuedMsg) - // Verify session stayed on step1 (no on_turn_start actions). - updated, err := repo.GetTaskSession(ctx, "s1") + // Verify task stayed on step1 (no on_turn_start actions). + updatedTask, err := repo.GetTask(ctx, "t1") if err != nil { - t.Fatalf("failed to get session: %v", err) + t.Fatalf("failed to get task: %v", err) } - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - got := "" - if updated.WorkflowStepID != nil { - got = *updated.WorkflowStepID - } - t.Errorf("expected session workflow step to remain 'step1', got %s", got) + if updatedTask.WorkflowStepID != "step1" { + t.Errorf("expected task workflow step to remain 'step1', got %q", updatedTask.WorkflowStepID) } } @@ -738,11 +729,6 @@ func TestHandleAgentCompleted_CleansUpExecution(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "") - // Clear WorkflowStepID so processOnTurnComplete skips workflow evaluation - session, _ := repo.GetTaskSession(ctx, "s1") - session.WorkflowStepID = nil - _ = repo.UpdateTaskSession(ctx, session) - taskRepo := newMockTaskRepo() agentMgr := &mockAgentManager{} svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) @@ -772,11 +758,6 @@ func TestHandleAgentFailed_CleansUpExecution(t *testing.T) { repo := setupTestRepo(t) seedSession(t, repo, "t1", "s1", "") - // Clear WorkflowStepID so workflow evaluation is skipped - session, _ := repo.GetTaskSession(ctx, "s1") - session.WorkflowStepID = nil - _ = repo.UpdateTaskSession(ctx, session) - taskRepo := newMockTaskRepo() agentMgr := &mockAgentManager{} svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr) @@ -841,16 +822,12 @@ func TestHandleAgentRunning_PassthroughGuard(t *testing.T) { svc.handleAgentRunning(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) // Workflow step must remain step1 because on_turn_start is skipped for ACP sessions. - updated, err := repo.GetTaskSession(ctx, "s1") + updatedTask, err := repo.GetTask(ctx, "t1") if err != nil { - t.Fatalf("failed to get session: %v", err) + t.Fatalf("failed to get task: %v", err) } - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - got := "" - if updated.WorkflowStepID != nil { - got = *updated.WorkflowStepID - } - t.Errorf("expected session workflow step to remain 'step1', got %s", got) + if updatedTask.WorkflowStepID != "step1" { + t.Errorf("expected task workflow step to remain 'step1', got %q", updatedTask.WorkflowStepID) } }) @@ -878,16 +855,12 @@ func TestHandleAgentRunning_PassthroughGuard(t *testing.T) { svc.handleAgentRunning(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) // Workflow step must move to step2 because passthrough sessions fire on_turn_start. - updated, err := repo.GetTaskSession(ctx, "s1") + updatedTask, err := repo.GetTask(ctx, "t1") if err != nil { - t.Fatalf("failed to get session: %v", err) + t.Fatalf("failed to get task: %v", err) } - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step2" { - got := "" - if updated.WorkflowStepID != nil { - got = *updated.WorkflowStepID - } - t.Errorf("expected session workflow step to be 'step2', got %s", got) + if updatedTask.WorkflowStepID != "step2" { + t.Errorf("expected task workflow step to be 'step2', got %q", updatedTask.WorkflowStepID) } }) diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index e3e50359ee..0dfaf27c7d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -18,20 +18,21 @@ import ( // processOnTurnComplete processes the on_turn_complete events for the current step. // Returns true if a transition occurred (step change happened). -func (s *Service) processOnTurnComplete(ctx context.Context, taskID string, session *models.TaskSession) bool { +func (s *Service) processOnTurnComplete(ctx context.Context, task *models.Task, session *models.TaskSession) bool { if session.ID == "" || s.workflowStepGetter == nil { return false } + taskID := task.ID sessionID := session.ID - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - s.logger.Debug("session has no workflow step, skipping transition", + if task.WorkflowStepID == "" { + s.logger.Debug("task has no workflow step, skipping transition", zap.String("session_id", sessionID)) return false } - workflowStepID := *session.WorkflowStepID + workflowStepID := task.WorkflowStepID // Get the current workflow step currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) @@ -119,18 +120,19 @@ func (s *Service) resolveTransitionTargetStep(ctx context.Context, taskID, sessi // processOnTurnStart processes the on_turn_start events for the current step. // This is called when a user sends a message. Returns true if a transition occurred. -func (s *Service) processOnTurnStart(ctx context.Context, taskID string, session *models.TaskSession) bool { +func (s *Service) processOnTurnStart(ctx context.Context, task *models.Task, session *models.TaskSession) bool { if session.ID == "" || s.workflowStepGetter == nil { return false } + taskID := task.ID sessionID := session.ID - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { + if task.WorkflowStepID == "" { return false } - workflowStepID := *session.WorkflowStepID + workflowStepID := task.WorkflowStepID // Get the current workflow step currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID) @@ -247,14 +249,6 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s )) } - // Update session's workflow step - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, toStepID); err != nil { - s.logger.Warn("failed to update session workflow step", - zap.String("session_id", sessionID), - zap.String("step_id", toStepID), - zap.Error(err)) - } - s.logger.Info("workflow transition completed", zap.String("task_id", taskID), zap.String("session_id", sessionID), @@ -851,10 +845,7 @@ func (s *Service) buildMachineState(ctx context.Context, task *models.Task, sess // assembleMachineState creates an engine.MachineState from pre-loaded models. // Shared by Service.buildMachineState and workflowStore.LoadState to avoid duplication. func assembleMachineState(task *models.Task, session *models.TaskSession, isPassthrough bool) engine.MachineState { - currentStepID := "" - if session.WorkflowStepID != nil { - currentStepID = *session.WorkflowStepID - } + currentStepID := task.WorkflowStepID var data map[string]any if session.Metadata != nil { if wd, ok := session.Metadata["workflow_data"].(map[string]any); ok { @@ -877,23 +868,23 @@ func assembleMachineState(task *models.Task, session *models.TaskSession, isPass // actions and drive step transitions. Falls back to the legacy method when the engine // is not initialized. Returns true if a step transition occurred. func (s *Service) processOnTurnCompleteViaEngine(ctx context.Context, taskID string, session *models.TaskSession) bool { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + s.logger.Warn("failed to load task for on_turn_complete", + zap.String("task_id", taskID), zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return false + } + if s.workflowEngine == nil { - return s.processOnTurnComplete(ctx, taskID, session) + return s.processOnTurnComplete(ctx, task, session) } if session.ID == "" || s.workflowStepGetter == nil { return false } - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - s.setSessionWaitingForInput(ctx, taskID, session.ID, session) - return false - } - - task, err := s.repo.GetTask(ctx, taskID) - if err != nil { - s.logger.Warn("failed to load task for engine on_turn_complete", - zap.String("task_id", taskID), zap.Error(err)) + if task.WorkflowStepID == "" { s.setSessionWaitingForInput(ctx, taskID, session.ID, session) return false } @@ -985,10 +976,6 @@ func (s *Service) applyEngineTransition( return false } - // Update in-memory session so subsequent calls (e.g. setSessionWaitingForInput) - // include the new workflow_step_id in the session.state_changed WS event. - session.WorkflowStepID = &result.ToStepID - if len(result.DataPatch) > 0 { if err := s.workflowStore.PersistData(ctx, session.ID, result.DataPatch); err != nil { s.logger.Warn("failed to persist workflow data patch", @@ -1010,22 +997,22 @@ func (s *Service) applyEngineTransition( // actions. Falls back to the legacy method when the engine is not initialized. // Returns true if a step transition occurred. func (s *Service) processOnTurnStartViaEngine(ctx context.Context, taskID string, session *models.TaskSession) bool { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + s.logger.Warn("failed to load task for on_turn_start", + zap.String("task_id", taskID), zap.Error(err)) + return false + } + if s.workflowEngine == nil { - return s.processOnTurnStart(ctx, taskID, session) + return s.processOnTurnStart(ctx, task, session) } if session.ID == "" || s.workflowStepGetter == nil { return false } - if session.WorkflowStepID == nil || *session.WorkflowStepID == "" { - return false - } - - task, err := s.repo.GetTask(ctx, taskID) - if err != nil { - s.logger.Warn("failed to load task for engine on_turn_start", - zap.String("task_id", taskID), zap.Error(err)) + if task.WorkflowStepID == "" { return false } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go index cb1ddbb947..7b3a49da43 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_triggers_test.go @@ -28,7 +28,7 @@ func TestProcessOnTurnComplete(t *testing.T) { _ = repo.CreateTaskSession(ctx, session) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if got { t.Error("expected false when session has no workflow step") } @@ -46,8 +46,9 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if got { t.Error("expected false when step has no on_turn_complete actions") } @@ -73,16 +74,17 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if !got { t.Error("expected true when move_to_next transitions") } - // Verify the session was updated to step2 - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step2" { - t.Errorf("expected session workflow step to be 'step2', got %v", updated.WorkflowStepID) + // Verify the task was updated to step2 + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step2" { + t.Errorf("expected task workflow step to be 'step2', got %q", updatedTask.WorkflowStepID) } }) @@ -106,15 +108,16 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if !got { t.Error("expected true when move_to_step transitions") } - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step3" { - t.Errorf("expected session workflow step to be 'step3', got %v", updated.WorkflowStepID) + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step3" { + t.Errorf("expected task workflow step to be 'step3', got %q", updatedTask.WorkflowStepID) } }) @@ -134,8 +137,9 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if got { t.Error("expected false when at last step with move_to_next (no next step)") } @@ -163,16 +167,17 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if got { t.Error("expected false when only action requires_approval") } - // Verify session step was NOT changed - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step1" { - t.Error("expected session to stay on step1") + // Verify task step was NOT changed + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step1" { + t.Error("expected task to stay on step1") } }) @@ -202,8 +207,9 @@ func TestProcessOnTurnComplete(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ = repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnComplete(ctx, "t1", session) + got := svc.processOnTurnComplete(ctx, task, session) if !got { t.Error("expected true when transition occurs alongside disable_plan_mode") } @@ -228,8 +234,9 @@ func TestProcessOnTurnStart(t *testing.T) { // Step getter returns (nil, nil) for unknown steps — must not panic. stepGetter := newMockStepGetter() svc := createTestService(repo, stepGetter, newMockTaskRepo()) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnStart(ctx, "t1", session) + got := svc.processOnTurnStart(ctx, task, session) if got { t.Error("expected false when step is nil") } @@ -246,8 +253,9 @@ func TestProcessOnTurnStart(t *testing.T) { } svc := createTestService(repo, stepGetter, newMockTaskRepo()) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnStart(ctx, "t1", session) + got := svc.processOnTurnStart(ctx, task, session) if got { t.Error("expected false when step has no on_turn_start actions") } @@ -273,15 +281,16 @@ func TestProcessOnTurnStart(t *testing.T) { taskRepo := newMockTaskRepo() svc := createTestService(repo, stepGetter, taskRepo) + task, _ := repo.GetTask(ctx, "t1") session, _ := repo.GetTaskSession(ctx, "s1") - got := svc.processOnTurnStart(ctx, "t1", session) + got := svc.processOnTurnStart(ctx, task, session) if !got { t.Error("expected true when move_to_next transitions") } - updated, _ := repo.GetTaskSession(ctx, "s1") - if updated.WorkflowStepID == nil || *updated.WorkflowStepID != "step2" { - t.Errorf("expected session workflow step to be 'step2', got %v", updated.WorkflowStepID) + updatedTask, _ := repo.GetTask(ctx, "t1") + if updatedTask.WorkflowStepID != "step2" { + t.Errorf("expected task workflow step to be 'step2', got %q", updatedTask.WorkflowStepID) } }) } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 2afdcf231b..739b567d02 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -43,6 +43,13 @@ type executorStore interface { UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error // Workspace GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) + // Task environment + GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) + CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + // Session history + plan (for context handover) + ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) + GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } // Common errors @@ -232,12 +239,17 @@ type LaunchAgentRequest struct { // Worktree configuration for concurrent agent execution UseWorktree bool // Whether to use a Git worktree for isolation + WorktreeID string // Existing worktree ID to reuse (skip creation if set) RepositoryID string // Repository ID for worktree tracking RepositoryPath string // Path to the main repository (for worktree creation) BaseBranch string // Base branch for the worktree (e.g., "main") CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch) WorktreeBranchPrefix string // Branch prefix for worktree branches PullBeforeWorktree bool // Whether to pull from remote before creating the worktree + + // Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/ + TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12") + RepoName string // Repository name used as subdirectory inside the task directory } // McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index cc6b6d9a1f..d57f5d4dd9 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -3,13 +3,16 @@ package executor import ( "context" "fmt" + "path/filepath" "strings" "sync" "time" "github.com/google/uuid" "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" ) @@ -216,6 +219,19 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi // Resolve agent profile to get model and other settings for snapshot agentProfileSnapshot, isPassthrough := e.resolveAgentProfileSnapshot(ctx, agentProfileID) + // Determine if this new session should become primary. + // Only the first session for a task is primary by default; subsequent sessions + // leave the existing primary unchanged so the user's explicit choice is preserved. + existingSessions, _ := e.repo.ListTaskSessions(ctx, task.ID) + hasPrimary := false + for _, s := range existingSessions { + if s.IsPrimary { + hasPrimary = true + break + } + } + isFirstSession := !hasPrimary + // Create agent session in database sessionID := uuid.New().String() now := time.Now().UTC() @@ -229,13 +245,11 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi StartedAt: now, UpdatedAt: now, AgentProfileSnapshot: agentProfileSnapshot, - IsPrimary: true, + IsPrimary: isFirstSession, IsPassthrough: isPassthrough, Metadata: metadata, } - if workflowStepID != "" { - session.WorkflowStepID = &workflowStepID - } + // workflow_step_id is a task-level field; no longer stored on sessions. // Store executor profile ID on session if executorProfileID != "" { @@ -259,12 +273,15 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi return "", err } - // Clear primary flag on any other sessions for this task - if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil { - e.logger.Warn("failed to update primary session flag", - zap.String("task_id", task.ID), - zap.String("session_id", sessionID), - zap.Error(err)) + // Set primary flag only for the first session (no existing primary). + // Subsequent sessions do not override the established primary. + if isFirstSession { + if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil { + e.logger.Warn("failed to update primary session flag", + zap.String("task_id", task.ID), + zap.String("session_id", sessionID), + zap.Error(err)) + } } e.logger.Info("session entry created", @@ -319,6 +336,9 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, fmt.Errorf("session does not belong to task") } + // Inject session handover context if there are previous sessions for this task. + prompt = e.injectHandoverIfNeeded(ctx, task.ID, sessionID, prompt) + // Fast path: workspace already launched (e.g., from PrepareSession with workspace). // Only start the agent subprocess if requested; otherwise return early. if session.AgentExecutionID != "" { @@ -345,6 +365,15 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses req.Attachments = opts.Attachments } + // Check for an existing task environment to reuse its worktree + existingEnv, _ := e.repo.GetTaskEnvironmentByTaskID(ctx, task.ID) + if existingEnv != nil && existingEnv.WorktreeID != "" && req.UseWorktree { + req.WorktreeID = existingEnv.WorktreeID + e.logger.Info("reusing existing task environment worktree", + zap.String("task_id", task.ID), + zap.String("worktree_id", existingEnv.WorktreeID)) + } + e.logger.Info("launching agent for prepared session", zap.String("task_id", task.ID), zap.String("session_id", sessionID), @@ -360,6 +389,9 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses return nil, e.handleLaunchFailure(ctx, task.ID, sessionID, err) } + // Create or update the task environment with launch results + e.persistTaskEnvironment(ctx, task.ID, session, existingEnv, req, resp, execCfg) + // Capture the current HEAD commit as the base commit for this session asynchronously. // This allows us to filter git log to only show commits made during the session. // We do this async to avoid delaying session launch while waiting for agentctl to be ready. @@ -478,6 +510,11 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s req.CheckoutBranch = repoInfo.CheckoutBranch req.WorktreeBranchPrefix = repoInfo.WorktreeBranchPrefix req.PullBeforeWorktree = repoInfo.PullBeforeWorktree + // Task directory mode: place worktree inside per-task directory + if req.UseWorktree && repoInfo.Repository != nil && repoInfo.Repository.Name != "" { + req.TaskDirName = worktree.SemanticWorktreeName(task.Title, worktree.SmallSuffix(3)) + req.RepoName = repoInfo.Repository.Name + } if repoInfo.Repository != nil && repoInfo.Repository.SetupScript != "" { if metadata == nil { metadata = make(map[string]interface{}) @@ -654,3 +691,98 @@ func (e *Executor) captureBaseCommit(ctx context.Context, sessionID string) { zap.String("base_commit", baseCommit), zap.String("head_commit", status.HeadCommit)) } + +// injectHandoverIfNeeded prepends session handover context to the prompt when the task +// already has previous sessions. The context includes the session count and the task plan +// (if one exists) so the new agent avoids repeating already-completed work. +func (e *Executor) injectHandoverIfNeeded(ctx context.Context, taskID, currentSessionID, prompt string) string { + sessions, err := e.repo.ListTaskSessions(ctx, taskID) + if err != nil { + e.logger.Warn("failed to list sessions for handover context", + zap.String("task_id", taskID), + zap.Error(err)) + return prompt + } + + // Count previous sessions (exclude the current one being launched). + var previousCount int + for _, s := range sessions { + if s.ID != currentSessionID { + previousCount++ + } + } + if previousCount == 0 { + return prompt + } + + // Build the plan section if a plan exists. + var planSection string + plan, err := e.repo.GetTaskPlan(ctx, taskID) + if err == nil && plan != nil && plan.Content != "" { + planSection = fmt.Sprintf("\nThe task has an implementation plan:\n\n%s\n", plan.Content) + } + + e.logger.Info("injecting session handover context", + zap.String("task_id", taskID), + zap.String("session_id", currentSessionID), + zap.Int("previous_sessions", previousCount)) + + return sysprompt.InjectSessionHandover(previousCount, planSection, prompt) +} + +// persistTaskEnvironment creates or updates the task environment record after a successful launch. +// It also links the session to the environment via TaskEnvironmentID. +func (e *Executor) persistTaskEnvironment( + ctx context.Context, + taskID string, + session *models.TaskSession, + existingEnv *models.TaskEnvironment, + req *LaunchAgentRequest, + resp *LaunchAgentResponse, + execCfg executorConfig, +) { + if existingEnv != nil { + // Update the existing environment with new execution info + existingEnv.AgentExecutionID = resp.AgentExecutionID + existingEnv.Status = models.TaskEnvironmentStatusReady + if err := e.repo.UpdateTaskEnvironment(ctx, existingEnv); err != nil { + e.logger.Warn("failed to update task environment", + zap.String("task_id", taskID), + zap.String("env_id", existingEnv.ID), + zap.Error(err)) + } + session.TaskEnvironmentID = existingEnv.ID + return + } + + // Create a new task environment + workspacePath := resp.WorktreePath + if workspacePath == "" { + workspacePath = req.RepositoryPath + } + // Task directory mode: WorkspacePath = task root, WorktreePath = repo subdir + if req.TaskDirName != "" && resp.WorktreePath != "" { + workspacePath = filepath.Dir(resp.WorktreePath) + } + env := &models.TaskEnvironment{ + TaskID: taskID, + RepositoryID: req.RepositoryID, + ExecutorType: req.ExecutorType, + ExecutorID: execCfg.ExecutorID, + ExecutorProfileID: session.ExecutorProfileID, + AgentExecutionID: resp.AgentExecutionID, + Status: models.TaskEnvironmentStatusReady, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + WorkspacePath: workspacePath, + ContainerID: resp.ContainerID, + } + if err := e.repo.CreateTaskEnvironment(ctx, env); err != nil { + e.logger.Warn("failed to create task environment", + zap.String("task_id", taskID), + zap.Error(err)) + return + } + session.TaskEnvironmentID = env.ID +} diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index e29e3667ac..4e546d512d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -22,6 +22,9 @@ func (e *Executor) Stop(ctx context.Context, sessionID string, reason string, fo } // stopWithSession stops an active execution using an already-loaded session. +// It marks the session CANCELLED in the database immediately so the WebSocket +// caller can return quickly, then terminates the agent process asynchronously +// to avoid blocking the caller on agentctl's blocking stop HTTP call. func (e *Executor) stopWithSession(ctx context.Context, session *models.TaskSession, reason string, force bool) error { if session.AgentExecutionID == "" { return ErrExecutionNotFound @@ -34,22 +37,27 @@ func (e *Executor) stopWithSession(ctx context.Context, session *models.TaskSess zap.String("reason", reason), zap.Bool("force", force)) - err := e.agentManager.StopAgentWithReason(ctx, session.AgentExecutionID, reason, force) - if err != nil { - // Log the error but continue to clean up execution state - // The agent instance may already be gone (container stopped externally) - e.logger.Warn("failed to stop agent (may already be stopped)", - zap.String("session_id", session.ID), - zap.Error(err)) - } - - // Update database - if dbErr := e.repo.UpdateTaskSessionState(ctx, session.ID, models.TaskSessionStateCancelled, reason); dbErr != nil { - e.logger.Error("failed to update agent session status in database", + // Mark the session CANCELLED and publish a WS event immediately so the + // frontend updates without waiting for the agent process to exit. + if dbErr := e.updateSessionState(ctx, session.TaskID, session.ID, models.TaskSessionStateCancelled, reason); dbErr != nil { + e.logger.Error("failed to update agent session status", zap.String("session_id", session.ID), zap.Error(dbErr)) } + // Terminate the agent process in the background — agentctl's stop endpoint + // blocks until the process exits, which can exceed the WS request timeout. + stopCtx := context.WithoutCancel(ctx) + executionID := session.AgentExecutionID + go func() { + if err := e.agentManager.StopAgentWithReason(stopCtx, executionID, reason, force); err != nil { + // Log the error; the agent instance may already be gone + e.logger.Warn("failed to stop agent (may already be stopped)", + zap.String("session_id", session.ID), + zap.Error(err)) + } + }() + return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 6a2519ce60..605d105347 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -566,6 +566,17 @@ func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Enviro return nil, nil } +// Task environment operations +func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { + return nil, nil +} +func (m *mockRepository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + return nil +} +func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + return nil +} + // Task Plan operations func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil } func (m *mockRepository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 8b0c218f57..009f41554f 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -42,6 +42,9 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionSessionLaunch, h.wsLaunchSession) d.RegisterFunc(ws.ActionSessionRecover, h.wsRecoverSession) d.RegisterFunc(ws.ActionSessionResetContext, h.wsResetContext) + d.RegisterFunc(ws.ActionSessionStop, h.wsStopSession) + d.RegisterFunc(ws.ActionSessionDelete, h.wsDeleteSession) + d.RegisterFunc(ws.ActionSessionSetPrimary, h.wsSetPrimarySession) d.RegisterFunc(ws.ActionGitHubCheckSessionPR, h.wsCheckSessionPR) } @@ -487,6 +490,65 @@ func (h *Handlers) wsGetTaskSessionStatus(ctx context.Context, msg *ws.Message) return ws.NewResponse(msg.ID, msg.Action, resp) } +type wsSessionActionRequest struct { + SessionID string `json:"session_id"` + Reason string `json:"reason,omitempty"` + Force bool `json:"force,omitempty"` +} + +func (h *Handlers) parseSessionAction(msg *ws.Message) (*wsSessionActionRequest, *ws.Message) { + var req wsSessionActionRequest + if err := msg.ParsePayload(&req); err != nil { + resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return nil, resp + } + if req.SessionID == "" { + resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + return nil, resp + } + return &req, nil +} + +func (h *Handlers) wsStopSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + req, errResp := h.parseSessionAction(msg) + if errResp != nil { + return errResp, nil + } + reason := req.Reason + if reason == "" { + reason = "stopped via API" + } + if err := h.service.StopSession(ctx, req.SessionID, reason, req.Force); err != nil { + h.logger.Error("failed to stop session", zap.String("session_id", req.SessionID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to stop session: "+err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) +} + +func (h *Handlers) wsDeleteSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + req, errResp := h.parseSessionAction(msg) + if errResp != nil { + return errResp, nil + } + if err := h.service.DeleteSession(ctx, req.SessionID); err != nil { + h.logger.Error("failed to delete session", zap.String("session_id", req.SessionID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete session: "+err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) +} + +func (h *Handlers) wsSetPrimarySession(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + req, errResp := h.parseSessionAction(msg) + if errResp != nil { + return errResp, nil + } + if err := h.service.SetPrimarySession(ctx, req.SessionID); err != nil { + h.logger.Error("failed to set primary session", zap.String("session_id", req.SessionID), zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to set primary session: "+err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, dto.SuccessResponse{Success: true}) +} + type wsCancelAgentRequest struct { SessionID string `json:"session_id"` } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 4deff1e2d9..e80b344487 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -110,7 +110,6 @@ type repoStore interface { GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) CreateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error - SetSessionPrimary(ctx context.Context, sessionID string) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error @@ -118,6 +117,8 @@ type repoStore interface { UpdateRepository(ctx context.Context, repository *models.Repository) error GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) + // Session history + plan (for context handover) + GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) } // sessionExecutorStore is the minimal repository interface needed by the orchestrator service. @@ -128,7 +129,6 @@ type sessionExecutorStore interface { UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error UpdateTaskSessionBaseCommit(ctx context.Context, id string, baseCommitSHA string) error ClearSessionExecutionID(ctx context.Context, id string) error - UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error // Executor running state @@ -147,6 +147,14 @@ type sessionExecutorStore interface { CreateSessionCommit(ctx context.Context, commit *models.SessionCommit) error GetSessionCommits(ctx context.Context, sessionID string) ([]*models.SessionCommit, error) DeleteSessionCommit(ctx context.Context, id string) error + // Session listing + primary/delete + ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) + SetSessionPrimary(ctx context.Context, sessionID string) error + DeleteTaskSession(ctx context.Context, id string) error + // Task environment + GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) + CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error } // Service is the main orchestrator service diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 81f9e22daf..9c040b1653 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -283,12 +283,13 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag // Apply workflow step prompt wrapping and plan mode injection. // Called unconditionally so workflow-step prompt composition (prefix/suffix) // applies even when plan mode is not requested. + // Re-read the task after on_turn_start may have changed the workflow step. // Ephemeral tasks skip workflow step processing since they have no workflow. - stepID := "" - if session.WorkflowStepID != nil { - stepID = *session.WorkflowStepID + dbTask, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("failed to reload task after on_turn_start: %w", err) } - effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, stepID, planMode, task.IsEphemeral) + effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, taskID, sessionID, dbTask.WorkflowStepID, planMode, task.IsEphemeral) // Inject config context for config-mode sessions (dedicated settings chat) if cm, ok := session.Metadata["config_mode"].(bool); ok && cm { @@ -533,17 +534,26 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin return nil, fmt.Errorf("task session does not belong to task") } running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) - if err != nil || running == nil { + if (err != nil || running == nil) && + session.State != models.TaskSessionStateCancelled && + session.State != models.TaskSessionStateFailed { + // Executor record is required for non-terminal sessions. For cancelled/failed sessions + // the record may already have been cleaned up before the user clicked Resume — allow it. return nil, fmt.Errorf("session is not resumable: no executor record") } if err := validateSessionWorktrees(session); err != nil { return nil, err } - // Don't resume sessions that are in a terminal state. + // Completed sessions cannot be restarted — they require a new session. + // Failed sessions clear the resume token so the agent starts fresh (the old + // process crashed). Cancelled sessions keep the token so --resume restores + // the conversation when the user re-starts a manually stopped session. switch session.State { - case models.TaskSessionStateFailed, models.TaskSessionStateCompleted, models.TaskSessionStateCancelled: - return nil, fmt.Errorf("session is in terminal state %s and cannot be resumed", session.State) + case models.TaskSessionStateCompleted: + return nil, fmt.Errorf("session is completed and cannot be resumed; create a new session instead") + case models.TaskSessionStateFailed: + s.clearResumeToken(ctx, sessionID) } // Use context.WithoutCancel to prevent WebSocket request timeout from canceling the resume. @@ -610,7 +620,7 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("session does not belong to task") } - task, err := s.scheduler.GetTask(ctx, taskID) + dbTask, err := s.repo.GetTask(ctx, taskID) if err != nil { return fmt.Errorf("failed to get task: %w", err) } @@ -619,9 +629,9 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes") } - s.advanceSessionWorkflowStep(ctx, sessionID, workflowStepID, session) + s.advanceTaskWorkflowStep(ctx, dbTask, workflowStepID, session) - effectivePrompt := s.buildWorkflowPrompt(task.Description, step, taskID, sessionID) + effectivePrompt := s.buildWorkflowPrompt(dbTask.Description, step, taskID, sessionID) if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil { return err @@ -643,21 +653,23 @@ func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessi return nil } -// advanceSessionWorkflowStep updates the session's workflow step and clears review status if the step changed. -func (s *Service) advanceSessionWorkflowStep(ctx context.Context, sessionID, workflowStepID string, session *models.TaskSession) { - if session.WorkflowStepID != nil && *session.WorkflowStepID == workflowStepID { +// advanceTaskWorkflowStep updates the task's workflow step and clears session review status if the step changed. +func (s *Service) advanceTaskWorkflowStep(ctx context.Context, task *models.Task, workflowStepID string, session *models.TaskSession) { + if task.WorkflowStepID == workflowStepID { return } - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil { - s.logger.Warn("failed to update session workflow step", - zap.String("session_id", sessionID), + task.WorkflowStepID = workflowStepID + task.UpdatedAt = time.Now().UTC() + if err := s.repo.UpdateTask(ctx, task); err != nil { + s.logger.Warn("failed to update task workflow step", + zap.String("task_id", task.ID), zap.String("workflow_step_id", workflowStepID), zap.Error(err)) } if session.ReviewStatus != nil && *session.ReviewStatus != "" { - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { + if err := s.repo.UpdateSessionReviewStatus(ctx, session.ID, ""); err != nil { s.logger.Warn("failed to clear session review status", - zap.String("session_id", sessionID), + zap.String("session_id", session.ID), zap.Error(err)) } } @@ -1022,6 +1034,102 @@ func (s *Service) StopSession(ctx context.Context, sessionID string, reason stri return s.executor.Stop(ctx, sessionID, reason, force) } +// DeleteSession deletes a session that is not currently running. +func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("session not found: %w", err) + } + + // Prevent deleting active sessions + switch session.State { + case models.TaskSessionStateRunning, models.TaskSessionStateStarting: + return fmt.Errorf("cannot delete session in %s state — stop it first", session.State) + } + + taskID := session.TaskID + wasPrimary := session.IsPrimary + + s.logger.Info("deleting session", + zap.String("session_id", sessionID), + zap.String("task_id", taskID), + zap.String("state", string(session.State)), + zap.Bool("was_primary", wasPrimary)) + + if err := s.repo.DeleteTaskSession(ctx, sessionID); err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + + // Auto-promote another session if we deleted the primary + if wasPrimary { + s.promoteNextPrimaryAfterRemoval(ctx, taskID, sessionID) + } + + return nil +} + +// promoteNextPrimaryAfterRemoval picks the best remaining session as primary +// after a session is deleted. Prefers RUNNING > active > any remaining. +func (s *Service) promoteNextPrimaryAfterRemoval(ctx context.Context, taskID, deletedSessionID string) { + sessions, err := s.repo.ListTaskSessions(ctx, taskID) + if err != nil || len(sessions) == 0 { + return + } + var candidate string + for _, sess := range sessions { + if sess.ID == deletedSessionID { + continue + } + if sess.State == models.TaskSessionStateRunning { + candidate = sess.ID + break + } + if candidate == "" { + candidate = sess.ID + } else if isActiveSessionState(sess.State) { + // Prefer active over terminal + candidate = sess.ID + } + } + if candidate != "" { + if err := s.SetPrimarySession(ctx, candidate); err != nil { + s.logger.Warn("failed to auto-promote primary after delete", + zap.String("task_id", taskID), + zap.String("candidate", candidate), + zap.Error(err)) + } + } +} + +// SetPrimarySession marks a session as the primary session for its task +// and broadcasts a task.updated event so the frontend reflects the change. +func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error { + if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil { + return fmt.Errorf("failed to set session as primary: %w", err) + } + + // Broadcast task.updated so frontend updates the primary star indicator. + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + s.logger.Warn("failed to fetch session after setting primary", zap.Error(err)) + return nil + } + task, err := s.repo.GetTask(ctx, session.TaskID) + if err != nil { + s.logger.Warn("failed to fetch task after setting primary", zap.Error(err)) + return nil + } + if s.eventBus != nil { + payload := buildTaskEventPayload(task) + payload["primary_session_id"] = sessionID + payload["primary_session_state"] = string(session.State) + _ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent( + events.TaskUpdated, "orchestrator", payload, + )) + } + return nil +} + // StopExecution stops agent execution for a specific execution ID. func (s *Service) StopExecution(ctx context.Context, executionID string, reason string, force bool) error { s.logger.Info("stopping execution", diff --git a/apps/backend/internal/orchestrator/workflow_e2e_test.go b/apps/backend/internal/orchestrator/workflow_e2e_test.go index f1222839d8..ea233f3e44 100644 --- a/apps/backend/internal/orchestrator/workflow_e2e_test.go +++ b/apps/backend/internal/orchestrator/workflow_e2e_test.go @@ -319,20 +319,20 @@ func setSessionState(t *testing.T, ctx context.Context, repo sessionExecutorStor } } -// assertStepByName verifies the session's current workflow step matches the expected step name. +// assertStepByName verifies the task's current workflow step matches the expected step name. func assertStepByName(t *testing.T, ctx context.Context, repo sessionExecutorStore, sessionID, expectName string, nameToID map[string]string) { t.Helper() session, err := repo.GetTaskSession(ctx, sessionID) if err != nil { t.Fatalf("failed to load session: %v", err) } - expectID := nameToID[expectName] - gotID := "" - if session.WorkflowStepID != nil { - gotID = *session.WorkflowStepID + task, err := repo.GetTask(ctx, session.TaskID) + if err != nil { + t.Fatalf("failed to load task: %v", err) } - if gotID != expectID { - t.Errorf("session step = %q (want %q for %q)", gotID, expectID, expectName) + expectID := nameToID[expectName] + if task.WorkflowStepID != expectID { + t.Errorf("task step = %q (want %q for %q)", task.WorkflowStepID, expectID, expectName) } } diff --git a/apps/backend/internal/orchestrator/workflow_store.go b/apps/backend/internal/orchestrator/workflow_store.go index ee00c1ddc6..2794f6bfef 100644 --- a/apps/backend/internal/orchestrator/workflow_store.go +++ b/apps/backend/internal/orchestrator/workflow_store.go @@ -111,13 +111,6 @@ func (s *workflowStore) ApplyTransition(ctx context.Context, taskID, sessionID, )) } - if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, toStepID); err != nil { - s.logger.Warn("failed to update session workflow step", - zap.String("session_id", sessionID), - zap.String("step_id", toStepID), - zap.Error(err)) - } - if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil { s.logger.Warn("failed to clear session review status", zap.String("session_id", sessionID), diff --git a/apps/backend/internal/orchestrator/workflow_store_test.go b/apps/backend/internal/orchestrator/workflow_store_test.go index 706a3024e0..f26aebd8a7 100644 --- a/apps/backend/internal/orchestrator/workflow_store_test.go +++ b/apps/backend/internal/orchestrator/workflow_store_test.go @@ -127,16 +127,11 @@ func TestWorkflowStore_ApplyTransition(t *testing.T) { t.Errorf("expected task WorkflowStepID %q, got %q", "step2", task.WorkflowStepID) } - // Verify session's workflow step is updated + // Verify review status is cleared on session session, err := repo.GetTaskSession(ctx, "s1") if err != nil { t.Fatalf("failed to get session: %v", err) } - if session.WorkflowStepID == nil || *session.WorkflowStepID != "step2" { - t.Errorf("expected session WorkflowStepID %q, got %v", "step2", session.WorkflowStepID) - } - - // Verify review status is cleared if session.ReviewStatus != nil && *session.ReviewStatus != "" { t.Errorf("expected review status to be cleared, got %q", *session.ReviewStatus) } diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go index a1f9347407..8bb2136536 100644 --- a/apps/backend/internal/sysprompt/sysprompt.go +++ b/apps/backend/internal/sysprompt/sysprompt.go @@ -196,6 +196,26 @@ func InjectPlanMode(prompt string) string { return Wrap(PlanMode) + "\n\n" + prompt } +// SessionHandoverContext is injected when a new session starts for a task that +// already has previous sessions. It gives the agent awareness of prior work. +const SessionHandoverContext = `CONTEXT FROM PREVIOUS SESSIONS: +This task has had %d previous session(s). You are starting a new session on the same workspace. +Any code changes from previous sessions are already present in the working directory. +%s +Review the current state of the codebase before making changes — previous sessions may have +already completed part of the work. Do not repeat work that is already done.` + +// FormatSessionHandover formats the session handover context. +// planSection should be pre-formatted (empty string if no plan exists). +func FormatSessionHandover(sessionCount int, planSection string) string { + return fmt.Sprintf(SessionHandoverContext, sessionCount, planSection) +} + +// InjectSessionHandover prepends session handover context to a prompt, wrapped in system tags. +func InjectSessionHandover(sessionCount int, planSection, prompt string) string { + return Wrap(FormatSessionHandover(sessionCount, planSection)) + "\n\n" + prompt +} + // InterpolatePlaceholders replaces placeholders in prompt templates with actual values. // Supported placeholders: // - {task_id} - the task ID diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index a1f2c4bc70..70244655c0 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -128,6 +128,7 @@ type TaskDTO struct { PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"` PrimarySessionState *string `json:"primary_session_state,omitempty"` IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` + ParentID string `json:"parent_id,omitempty"` ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -152,6 +153,7 @@ type TaskSessionDTO struct { ContainerID string `json:"container_id,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` EnvironmentID string `json:"environment_id,omitempty"` RepositoryID string `json:"repository_id,omitempty"` BaseBranch string `json:"base_branch,omitempty"` @@ -170,38 +172,39 @@ type TaskSessionDTO struct { CompletedAt *time.Time `json:"completed_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` // Workflow fields - IsPrimary bool `json:"is_primary"` - IsPassthrough bool `json:"is_passthrough"` - WorkflowStepID *string `json:"workflow_step_id,omitempty"` - ReviewStatus *string `json:"review_status,omitempty"` + IsPrimary bool `json:"is_primary"` + IsPassthrough bool `json:"is_passthrough"` + ReviewStatus *string `json:"review_status,omitempty"` + TaskEnvironmentID string `json:"task_environment_id,omitempty"` } // TaskSessionSummaryDTO is a lightweight version of TaskSessionDTO without snapshot fields. // Used for list endpoints where snapshots are not needed, reducing response size by ~40-60%. type TaskSessionSummaryDTO struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - EnvironmentID string `json:"environment_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - BaseCommitSHA string `json:"base_commit_sha,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - State models.TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - IsPrimary bool `json:"is_primary"` - IsPassthrough bool `json:"is_passthrough"` - WorkflowStepID *string `json:"workflow_step_id,omitempty"` - ReviewStatus *string `json:"review_status,omitempty"` + ID string `json:"id"` + TaskID string `json:"task_id"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BaseCommitSHA string `json:"base_commit_sha,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + State models.TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + IsPrimary bool `json:"is_primary"` + IsPassthrough bool `json:"is_passthrough"` + ReviewStatus *string `json:"review_status,omitempty"` + TaskEnvironmentID string `json:"task_environment_id,omitempty"` } // ListTaskSessionSummariesResponse is the list response using summary DTOs. @@ -510,6 +513,7 @@ func FromTaskWithSessionInfo( PrimaryWorkingDirectory: primaryWorkingDirectory, PrimarySessionState: primarySessionState, IsRemoteExecutor: primaryExecutorType != nil && models.IsRemoteExecutorType(models.ExecutorType(*primaryExecutorType)), + ParentID: task.ParentID, ArchivedAt: task.ArchivedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, @@ -520,26 +524,27 @@ func FromTaskWithSessionInfo( // FromTaskSessionSummary converts a session model to a summary DTO (no snapshot fields). func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO { result := TaskSessionSummaryDTO{ - ID: session.ID, - TaskID: session.TaskID, - AgentExecutionID: session.AgentExecutionID, - ContainerID: session.ContainerID, - AgentProfileID: session.AgentProfileID, - ExecutorID: session.ExecutorID, - EnvironmentID: session.EnvironmentID, - RepositoryID: session.RepositoryID, - BaseBranch: session.BaseBranch, - BaseCommitSHA: session.BaseCommitSHA, - State: session.State, - ErrorMessage: session.ErrorMessage, - Metadata: session.Metadata, - StartedAt: session.StartedAt, - CompletedAt: session.CompletedAt, - UpdatedAt: session.UpdatedAt, - IsPrimary: session.IsPrimary, - IsPassthrough: session.IsPassthrough, - WorkflowStepID: session.WorkflowStepID, - ReviewStatus: session.ReviewStatus, + ID: session.ID, + TaskID: session.TaskID, + AgentExecutionID: session.AgentExecutionID, + ContainerID: session.ContainerID, + AgentProfileID: session.AgentProfileID, + ExecutorID: session.ExecutorID, + ExecutorProfileID: session.ExecutorProfileID, + EnvironmentID: session.EnvironmentID, + RepositoryID: session.RepositoryID, + BaseBranch: session.BaseBranch, + BaseCommitSHA: session.BaseCommitSHA, + State: session.State, + ErrorMessage: session.ErrorMessage, + Metadata: session.Metadata, + StartedAt: session.StartedAt, + CompletedAt: session.CompletedAt, + UpdatedAt: session.UpdatedAt, + IsPrimary: session.IsPrimary, + IsPassthrough: session.IsPassthrough, + ReviewStatus: session.ReviewStatus, + TaskEnvironmentID: session.TaskEnvironmentID, } if len(session.Worktrees) > 0 { result.WorktreeID = session.Worktrees[0].WorktreeID @@ -557,6 +562,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { ContainerID: session.ContainerID, AgentProfileID: session.AgentProfileID, ExecutorID: session.ExecutorID, + ExecutorProfileID: session.ExecutorProfileID, EnvironmentID: session.EnvironmentID, RepositoryID: session.RepositoryID, BaseBranch: session.BaseBranch, @@ -572,10 +578,10 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { CompletedAt: session.CompletedAt, UpdatedAt: session.UpdatedAt, // Workflow fields - IsPrimary: session.IsPrimary, - IsPassthrough: session.IsPassthrough, - WorkflowStepID: session.WorkflowStepID, - ReviewStatus: session.ReviewStatus, + IsPrimary: session.IsPrimary, + IsPassthrough: session.IsPassthrough, + ReviewStatus: session.ReviewStatus, + TaskEnvironmentID: session.TaskEnvironmentID, } if len(session.Worktrees) > 0 { result.WorktreeID = session.Worktrees[0].WorktreeID diff --git a/apps/backend/internal/task/handlers/message_handlers_test.go b/apps/backend/internal/task/handlers/message_handlers_test.go index 4ad03b400d..9dd344cc24 100644 --- a/apps/backend/internal/task/handlers/message_handlers_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_test.go @@ -54,7 +54,8 @@ func newTestMessageHandlers(t *testing.T, repo *sessionStateSequencer) *MessageH Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, RepoEntities: repo, - Executors: repo, Environments: repo, Reviews: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, }, nil, log, service.RepositoryDiscoveryConfig{}) return NewMessageHandlers(svc, nil, log) } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 060bf19b6b..1440a6457e 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -330,6 +330,26 @@ func (m *mockRepository) Close() error { return nil } +// Task environment operations +func (m *mockRepository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + return nil +} +func (m *mockRepository) GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) { + return nil, nil +} +func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironment(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error { + return nil +} + // Git Snapshot operations func (m *mockRepository) CreateGitSnapshot(ctx context.Context, snapshot *models.GitSnapshot) error { return nil @@ -442,7 +462,8 @@ func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, RepoEntities: repo, - Executors: repo, Environments: repo, Reviews: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, }, nil, log, service.RepositoryDiscoveryConfig{}) } @@ -594,7 +615,8 @@ func TestStopProcessRejectsDifferentSession(t *testing.T) { Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, RepoEntities: repo, - Executors: repo, Environments: repo, Reviews: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, }, nil, log, service.RepositoryDiscoveryConfig{}) lifecycleMgr := newLifecycleManager(t, log) client := newAgentctlClient(t, server.URL, log) @@ -658,7 +680,8 @@ func TestStopProcessAgentctlUnavailable(t *testing.T) { Workspaces: repo, Tasks: repo, TaskRepos: repo, Workflows: repo, Messages: repo, Turns: repo, Sessions: repo, GitSnapshots: repo, RepoEntities: repo, - Executors: repo, Environments: repo, Reviews: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, }, nil, log, service.RepositoryDiscoveryConfig{}) lifecycleMgr := newLifecycleManager(t, log) client := newAgentctlClient(t, server.URL, log) diff --git a/apps/backend/internal/task/handlers/task_environment_handlers.go b/apps/backend/internal/task/handlers/task_environment_handlers.go new file mode 100644 index 0000000000..9a75b19e96 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_environment_handlers.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *TaskHandlers) httpGetTaskEnvironment(c *gin.Context) { + taskID := c.Param("id") + env, err := h.service.GetTaskEnvironmentByTaskID(c.Request.Context(), taskID) + if err != nil { + handleNotFound(c, h.logger, err, "task environment not found") + return + } + if env == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no environment for this task"}) + return + } + c.JSON(http.StatusOK, env.ToAPI()) +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 33173c2212..94f230d5c8 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -58,6 +58,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.GET("/tasks/:id", h.httpGetTask) api.GET("/task-sessions/:id", h.httpGetTaskSession) api.GET("/tasks/:id/sessions", h.httpListTaskSessions) + api.GET("/tasks/:id/environment", h.httpGetTaskEnvironment) api.GET("/task-sessions/:id/turns", h.httpListSessionTurns) api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index c01d3b540d..c3ed49667e 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -180,7 +180,13 @@ func (h *TaskHandlers) httpGetTask(c *gin.Context) { handleNotFound(c, h.logger, err, "task not found") return } - c.JSON(http.StatusOK, dto.FromTask(task)) + dtos, err := buildTaskDTOsWithSessionInfo(c.Request.Context(), h.service, []*models.Task{task}) + if err != nil { + h.logger.Error("failed to build task DTO with session info", zap.Error(err)) + c.JSON(http.StatusOK, dto.FromTask(task)) + return + } + c.JSON(http.StatusOK, dtos[0]) } func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) { @@ -329,6 +335,7 @@ type httpCreateTaskRequest struct { ExecutorProfileID string `json:"executor_profile_id,omitempty"` PlanMode bool `json:"plan_mode,omitempty"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` + ParentID string `json:"parent_id,omitempty"` } type createTaskResponse struct { @@ -427,6 +434,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) { Position: body.Position, Metadata: body.Metadata, PlanMode: body.PlanMode && !body.StartAgent, + ParentID: body.ParentID, }) if err != nil { handleNotFound(c, h.logger, err, "task not created") diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 3494c5b38a..5bf1550161 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -91,6 +91,7 @@ type wsCreateTaskRequest struct { ExecutorProfileID string `json:"executor_profile_id,omitempty"` PlanMode bool `json:"plan_mode,omitempty"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` + ParentID string `json:"parent_id,omitempty"` } func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -155,6 +156,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M Position: req.Position, Metadata: req.Metadata, PlanMode: req.PlanMode && !req.StartAgent, + ParentID: req.ParentID, }) if err != nil { h.logger.Error("failed to create task", zap.Error(err)) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index aa55eba0c5..11b94bbe0d 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -39,7 +39,8 @@ type Task struct { Position int `json:"position"` // Order within workflow step Metadata map[string]interface{} `json:"metadata,omitempty"` Repositories []*TaskRepository `json:"repositories,omitempty"` - IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -265,11 +266,13 @@ type TaskSession struct { CompletedAt *time.Time `json:"completed_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` + // Environment reference + TaskEnvironmentID string `json:"task_environment_id,omitempty"` // FK to task_environments for shared env + // Workflow-related fields - IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task - IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode - WorkflowStepID *string `json:"workflow_step_id,omitempty"` // Current workflow step ID - ReviewStatus *string `json:"review_status,omitempty"` // pending, approved + IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task + IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode + ReviewStatus *string `json:"review_status,omitempty"` // pending, approved } // ToAPI converts internal TaskSession to API type @@ -319,6 +322,9 @@ func (s *TaskSession) ToAPI() map[string]interface{} { result["repository_snapshot"] = s.RepositorySnapshot } result["is_passthrough"] = s.IsPassthrough + if s.TaskEnvironmentID != "" { + result["task_environment_id"] = s.TaskEnvironmentID + } return result } @@ -491,6 +497,80 @@ type Environment struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// TaskEnvironmentStatus represents the lifecycle state of a task execution environment. +type TaskEnvironmentStatus string + +const ( + TaskEnvironmentStatusCreating TaskEnvironmentStatus = "creating" + TaskEnvironmentStatusReady TaskEnvironmentStatus = "ready" + TaskEnvironmentStatusStopped TaskEnvironmentStatus = "stopped" + TaskEnvironmentStatusFailed TaskEnvironmentStatus = "failed" +) + +// TaskEnvironment represents a per-task execution environment instance. +// It owns the workspace (worktree/container/sandbox) and the agentctl control server. +// Multiple sessions can share the same TaskEnvironment. +type TaskEnvironment struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + RepositoryID string `json:"repository_id"` + ExecutorType string `json:"executor_type"` + ExecutorID string `json:"executor_id"` + ExecutorProfileID string `json:"executor_profile_id"` + AgentExecutionID string `json:"agent_execution_id"` // agentctl execution handle + ControlPort int `json:"control_port"` // agentctl control port + Status TaskEnvironmentStatus `json:"status"` + + // Type-specific fields + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + ContainerID string `json:"container_id,omitempty"` + SandboxID string `json:"sandbox_id,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToAPI converts internal TaskEnvironment to API map. +func (te *TaskEnvironment) ToAPI() map[string]interface{} { + result := map[string]interface{}{ + "id": te.ID, + "task_id": te.TaskID, + "repository_id": te.RepositoryID, + "executor_type": te.ExecutorType, + "executor_id": te.ExecutorID, + "executor_profile_id": te.ExecutorProfileID, + "status": string(te.Status), + "workspace_path": te.WorkspacePath, + "created_at": te.CreatedAt, + "updated_at": te.UpdatedAt, + } + if te.AgentExecutionID != "" { + result["agent_execution_id"] = te.AgentExecutionID + } + if te.ControlPort != 0 { + result["control_port"] = te.ControlPort + } + if te.WorktreeID != "" { + result["worktree_id"] = te.WorktreeID + } + if te.WorktreePath != "" { + result["worktree_path"] = te.WorktreePath + } + if te.WorktreeBranch != "" { + result["worktree_branch"] = te.WorktreeBranch + } + if te.ContainerID != "" { + result["container_id"] = te.ContainerID + } + if te.SandboxID != "" { + result["sandbox_id"] = te.SandboxID + } + return result +} + // TaskPlan represents a plan associated with a task type TaskPlan struct { ID string `json:"id"` @@ -544,5 +624,6 @@ func (t *Task) ToAPI() *v1.Task { UpdatedAt: t.UpdatedAt, Metadata: t.Metadata, IsEphemeral: t.IsEphemeral, + ParentID: t.ParentID, } } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 2a43fa3449..0f80b9f63b 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -106,7 +106,6 @@ type SessionRepository interface { GetSessionCountsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]int, error) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs []string) (map[string]*models.TaskSession, error) SetSessionPrimary(ctx context.Context, sessionID string) error - UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error } @@ -178,6 +177,16 @@ type EnvironmentRepository interface { ListEnvironments(ctx context.Context) ([]*models.Environment, error) } +// TaskEnvironmentRepository handles per-task execution environment instances. +type TaskEnvironmentRepository interface { + CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) + GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) + UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error + DeleteTaskEnvironment(ctx context.Context, id string) error + DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error +} + // ReviewRepository handles session file review records. type ReviewRepository interface { UpsertSessionFileReview(ctx context.Context, review *models.SessionFileReview) error diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 1a19a8158c..ca107ac12f 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -105,6 +106,9 @@ func (r *Repository) initSchema() error { if err := r.runMigrations(); err != nil { return err } + if err := r.backfillTaskEnvironments(); err != nil { + return err + } if err := r.ensureWorkspaceIndexes(); err != nil { return err } @@ -140,48 +144,67 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) // Add default_config_agent_profile_id column to workspaces (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) + // Add task_environment_id column to task_sessions for shared environment reference (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) + // Add parent_id column to tasks for subtask support (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows if err := r.migrateTasksRemoveWorkflowFK(); err != nil { return err } + // Remove deprecated workflow_step_id column from task_sessions + if err := r.migrateSessionsRemoveWorkflowStepID(); err != nil { + return err + } return nil } -// migrateTasksRemoveWorkflowFK removes the foreign key constraint on workflow_id -// to allow ephemeral tasks (quick chat) to have empty workflow_id. -// SQLite requires recreating the table to remove FK constraints. -func (r *Repository) migrateTasksRemoveWorkflowFK() error { - // Check if migration is needed by looking for the FK constraint +// recreateTable checks whether tableName's DDL contains triggerPhrase and, if so, +// runs statements inside a transaction with FK enforcement disabled. +// This is the standard SQLite pattern for dropping columns or FK constraints, +// since SQLite has no ALTER TABLE DROP COLUMN / DROP CONSTRAINT. +// Note: PRAGMA statements cannot run inside a transaction in SQLite, so FK enforcement +// is toggled outside the transaction. The writer pool must have MaxOpenConns(1) so that +// the PRAGMA and the subsequent transaction use the same connection. +func (r *Repository) recreateTable(tableName, triggerPhrase string, statements []string) error { var tableSql string - err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'`).Scan(&tableSql) + err := r.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`, tableName).Scan(&tableSql) if errors.Is(err, sql.ErrNoRows) { - return nil // Table doesn't exist yet, skip migration + return nil // Table doesn't exist yet; migration not applicable } if err != nil { - return fmt.Errorf("query tasks schema: %w", err) + return fmt.Errorf("query %s schema: %w", tableName, err) } - if !strings.Contains(tableSql, "FOREIGN KEY (workflow_id)") { - return nil // No FK constraint, migration not needed + if !strings.Contains(tableSql, triggerPhrase) { + return nil // Trigger phrase absent; migration already applied or not needed } - // Disable FK enforcement to prevent cascade deletes during table recreation. - // The writer pool has MaxOpenConns(1), so PRAGMA and subsequent operations use the same connection. - // Note: PRAGMA statements cannot run inside a transaction in SQLite. if _, err := r.db.Exec(`PRAGMA foreign_keys=OFF`); err != nil { return fmt.Errorf("disable foreign keys: %w", err) } defer func() { _, _ = r.db.Exec(`PRAGMA foreign_keys=ON`) }() - // Wrap migration in a transaction to avoid partial state on failure tx, err := r.db.Beginx() if err != nil { return fmt.Errorf("begin migration transaction: %w", err) } defer func() { _ = tx.Rollback() }() - // Recreate tasks table without the workflow_id FK constraint - // SQLite requires executing statements one at a time - statements := []string{ + for _, stmt := range statements { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("migration %s failed: %w", tableName, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration transaction: %w", err) + } + return nil +} + +// migrateTasksRemoveWorkflowFK removes the foreign key constraint on workflow_id +// to allow ephemeral tasks (quick chat) to have empty workflow_id. +func (r *Repository) migrateTasksRemoveWorkflowFK() error { + return r.recreateTable("tasks", "FOREIGN KEY (workflow_id)", []string{ `CREATE TABLE tasks_new ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', @@ -194,28 +217,177 @@ func (r *Repository) migrateTasksRemoveWorkflowFK() error { position INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}', is_ephemeral INTEGER NOT NULL DEFAULT 0, + parent_id TEXT DEFAULT '', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL )`, `INSERT INTO tasks_new SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, - state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks`, `DROP TABLE tasks`, `ALTER TABLE tasks_new RENAME TO tasks`, - // Use same index names as initCoreIndexes for consistency `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)`, + }) +} + +// migrateSessionsRemoveWorkflowStepID removes the deprecated workflow_step_id column +// from task_sessions. Workflow step is now tracked on the task, not the session. +func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { + return r.recreateTable("task_sessions", "workflow_step_id", []string{ + `CREATE TABLE task_sessions_new ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + agent_execution_id TEXT NOT NULL DEFAULT '', + container_id TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL, + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + environment_id TEXT DEFAULT '', + repository_id TEXT DEFAULT '', + base_branch TEXT DEFAULT '', + agent_profile_snapshot TEXT DEFAULT '{}', + executor_snapshot TEXT DEFAULT '{}', + environment_snapshot TEXT DEFAULT '{}', + repository_snapshot TEXT DEFAULT '{}', + state TEXT NOT NULL DEFAULT 'CREATED', + error_message TEXT DEFAULT '', + metadata TEXT DEFAULT '{}', + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + updated_at TIMESTAMP NOT NULL, + is_primary INTEGER DEFAULT 0, + is_passthrough INTEGER DEFAULT 0, + review_status TEXT DEFAULT '', + base_commit_sha TEXT DEFAULT '', + task_environment_id TEXT DEFAULT '', + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + )`, + `INSERT INTO task_sessions_new SELECT + id, task_id, agent_execution_id, container_id, agent_profile_id, + executor_id, executor_profile_id, environment_id, repository_id, base_branch, + agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, + state, error_message, metadata, started_at, completed_at, updated_at, + is_primary, is_passthrough, review_status, + COALESCE(base_commit_sha, ''), COALESCE(task_environment_id, '') + FROM task_sessions`, + `DROP TABLE task_sessions`, + `ALTER TABLE task_sessions_new RENAME TO task_sessions`, + `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id)`, + `CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state)`, + `CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state)`, + }) +} + +type backfillRow struct { + taskID, executorID, executorProfileID string + repositoryID, containerID string + startedAt string +} + +// backfillTaskEnvironments creates TaskEnvironment records for historical tasks +// that have sessions but no environment, and links orphaned sessions. +// Idempotent: tasks with existing environments are skipped. +func (r *Repository) backfillTaskEnvironments() error { + orphaned, err := r.findOrphanedTasks() + if err != nil { + return err } - for _, stmt := range statements { - if _, err := tx.Exec(stmt); err != nil { - return fmt.Errorf("migration failed: %w", err) + if len(orphaned) == 0 { + return nil + } + + tx, err := r.db.Begin() + if err != nil { + return fmt.Errorf("backfill: begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + for _, row := range orphaned { + if err := r.backfillSingleTask(tx, row); err != nil { + return err } } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit migration transaction: %w", err) + return tx.Commit() +} + +// findOrphanedTasks returns tasks that have sessions but no task_environments row. +func (r *Repository) findOrphanedTasks() ([]backfillRow, error) { + rows, err := r.db.Query(` + SELECT ts.task_id, + COALESCE(ts.executor_id, ''), + COALESCE(ts.executor_profile_id, ''), + COALESCE(ts.repository_id, ''), + COALESCE(ts.container_id, ''), + ts.started_at + FROM task_sessions ts + LEFT JOIN task_environments te ON te.task_id = ts.task_id + WHERE te.id IS NULL + GROUP BY ts.task_id + `) + if err != nil { + return nil, fmt.Errorf("backfill: query orphaned tasks: %w", err) + } + defer func() { _ = rows.Close() }() + + var orphaned []backfillRow + for rows.Next() { + var row backfillRow + if err := rows.Scan(&row.taskID, &row.executorID, &row.executorProfileID, + &row.repositoryID, &row.containerID, &row.startedAt); err != nil { + return nil, fmt.Errorf("backfill: scan: %w", err) + } + orphaned = append(orphaned, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("backfill: rows: %w", err) + } + return orphaned, nil +} + +// backfillSingleTask creates a task_environment and links sessions for one orphaned task. +func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error { + envID := uuid.New().String() + + // Look up executor type from executors table, default to "local_pc" + var executorType string + if err := tx.QueryRow(`SELECT type FROM executors WHERE id = ?`, row.executorID).Scan(&executorType); err != nil { + executorType = "local_pc" + } + + // Look up worktree info from task_session_worktrees (best effort) + var wtID, wtPath, wtBranch string + _ = tx.QueryRow(` + SELECT w.worktree_id, w.worktree_path, w.worktree_branch + FROM task_session_worktrees w + JOIN task_sessions ts ON ts.id = w.session_id + WHERE ts.task_id = ? + LIMIT 1 + `, row.taskID).Scan(&wtID, &wtPath, &wtBranch) + + // Insert task_environment with status "stopped" (historical, agentctl not running) + if _, err := tx.Exec(` + INSERT INTO task_environments ( + id, task_id, repository_id, executor_type, executor_id, + executor_profile_id, agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, '', 0, 'stopped', ?, ?, ?, '', ?, '', ?, datetime('now')) + `, envID, row.taskID, row.repositoryID, executorType, row.executorID, + row.executorProfileID, wtID, wtPath, wtBranch, row.containerID, row.startedAt); err != nil { + return fmt.Errorf("backfill: insert env for task %s: %w", row.taskID, err) + } + + // Link all sessions for this task that lack task_environment_id + if _, err := tx.Exec(` + UPDATE task_sessions + SET task_environment_id = ? + WHERE task_id = ? AND (task_environment_id = '' OR task_environment_id IS NULL) + `, envID, row.taskID); err != nil { + return fmt.Errorf("backfill: link sessions for task %s: %w", row.taskID, err) } return nil } @@ -494,9 +666,10 @@ func (r *Repository) initSessionWorktreeSchema() error { completed_at TIMESTAMP, updated_at TIMESTAMP NOT NULL, is_primary INTEGER DEFAULT 0, - workflow_step_id TEXT DEFAULT '', is_passthrough INTEGER DEFAULT 0, review_status TEXT DEFAULT '', + base_commit_sha TEXT DEFAULT '', + task_environment_id TEXT DEFAULT '', FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); @@ -504,6 +677,30 @@ func (r *Repository) initSessionWorktreeSchema() error { CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state); CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state); + CREATE TABLE IF NOT EXISTS task_environments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT DEFAULT '', + executor_type TEXT NOT NULL DEFAULT '', + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + agent_execution_id TEXT DEFAULT '', + control_port INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'creating', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + workspace_path TEXT DEFAULT '', + container_id TEXT DEFAULT '', + sandbox_id TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id); + CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status); + CREATE TABLE IF NOT EXISTS task_session_worktrees ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 333a251ccb..2a624a2739 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -200,15 +200,15 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.StartedAt, session.CompletedAt, session.UpdatedAt, - dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, - dialect.BoolToInt(session.IsPassthrough)) + dialect.BoolToInt(session.IsPrimary), session.ReviewStatus, + dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID) return err } @@ -235,7 +235,6 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr var completedAt sql.NullTime var isPrimary int var isPassthrough int - var workflowStepID sql.NullString var reviewStatus sql.NullString err := row.Scan( @@ -244,7 +243,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, + &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, ) if err == sql.ErrNoRows { @@ -257,9 +256,6 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } @@ -298,7 +294,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE id = ? `), id) return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found: %s", id)) @@ -311,7 +307,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string) repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1 `), taskID) return r.scanTaskSession(ctx, row, fmt.Sprintf("agent session not found for task: %s", taskID)) @@ -324,7 +320,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC LIMIT 1 @@ -363,14 +359,14 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task repository_id = ?, base_branch = ?, base_commit_sha = ?, agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?, state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?, - is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ? + is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ? WHERE id = ? `), session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON), string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt, - dialect.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus, - dialect.BoolToInt(session.IsPassthrough), + dialect.BoolToInt(session.IsPrimary), session.ReviewStatus, + dialect.BoolToInt(session.IsPassthrough), session.TaskEnvironmentID, session.ID) if err != nil { return err @@ -470,7 +466,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC `), taskID) if err != nil { @@ -494,7 +490,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC `) if err != nil { @@ -516,7 +512,7 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC `), taskID) if err != nil { @@ -662,7 +658,6 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { var completedAt sql.NullTime var isPrimary int var isPassthrough int - var workflowStepID sql.NullString var reviewStatus sql.NullString err := rows.Scan( @@ -671,7 +666,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { &session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, - &isPrimary, &workflowStepID, &reviewStatus, &isPassthrough, + &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, ) if err != nil { return nil, err @@ -680,9 +675,6 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { session.State = models.TaskSessionState(state) session.IsPrimary = isPrimary == 1 session.IsPassthrough = isPassthrough == 1 - if workflowStepID.Valid { - session.WorkflowStepID = &workflowStepID.String - } if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } @@ -868,7 +860,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin repository_id, base_branch, base_commit_sha, agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot, state, error_message, metadata, started_at, completed_at, updated_at, - is_primary, workflow_step_id, review_status, is_passthrough + is_primary, review_status, is_passthrough, task_environment_id FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1 `), taskID) return r.scanTaskSession(ctx, row, fmt.Sprintf("no primary session found for task: %s", taskID)) diff --git a/apps/backend/internal/task/repository/sqlite/session_workflow.go b/apps/backend/internal/task/repository/sqlite/session_workflow.go index 48e412f86c..68a1be103d 100644 --- a/apps/backend/internal/task/repository/sqlite/session_workflow.go +++ b/apps/backend/internal/task/repository/sqlite/session_workflow.go @@ -6,24 +6,6 @@ import ( "time" ) -// UpdateSessionWorkflowStep updates the workflow step for a session -func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error { - now := time.Now().UTC() - - result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ? - `), stepID, now, sessionID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("session not found: %s", sessionID) - } - return nil -} - // UpdateSessionReviewStatus updates the review status of a session func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { now := time.Now().UTC() diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 93782f2d4b..3e4b96f3c8 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -35,9 +35,9 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error { } _, err = tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.IsEphemeral, task.CreatedAt, task.UpdatedAt) + INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.IsEphemeral, task.ParentID, task.CreatedAt, task.UpdatedAt) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) @@ -54,9 +54,9 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro var metadata string var archivedAt sql.NullTime err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks WHERE id = ? - `), id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.IsEphemeral, &archivedAt, &task.CreatedAt, &task.UpdatedAt) + `), id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.IsEphemeral, &task.ParentID, &archivedAt, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -82,9 +82,9 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { } result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ? + UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, parent_id = ?, updated_at = ? WHERE id = ? - `), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID) + `), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.ParentID, task.UpdatedAt, task.ID) if err != nil { return err } @@ -126,7 +126,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTasks") defer span.End() rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks WHERE workflow_id = ? AND archived_at IS NULL AND is_ephemeral = 0 ORDER BY position @@ -162,7 +162,7 @@ func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string // ListTasksByWorkflowStep returns all non-archived, non-ephemeral tasks in a workflow step func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0 ORDER BY position `), workflowStepID) @@ -238,7 +238,7 @@ func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, archiveFilt return nil, 0, err } rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, archived_at, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks WHERE workspace_id = ?`+archiveFilter+` ORDER BY updated_at DESC @@ -284,7 +284,7 @@ func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, filter } selectQuery := fmt.Sprintf(` - SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.is_ephemeral, t.archived_at, t.created_at, t.updated_at + SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.is_ephemeral, t.parent_id, t.archived_at, t.created_at, t.updated_at FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id @@ -321,6 +321,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.Position, &metadata, &task.IsEphemeral, + &task.ParentID, &archivedAt, &task.CreatedAt, &task.UpdatedAt, @@ -355,7 +356,7 @@ func (r *Repository) ArchiveTask(ctx context.Context, id string) error { func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { drv := r.ro.DriverName() query := fmt.Sprintf(` - SELECT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.is_ephemeral, t.archived_at, t.created_at, t.updated_at + SELECT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.is_ephemeral, t.parent_id, t.archived_at, t.created_at, t.updated_at FROM tasks t JOIN workflow_steps ws ON ws.id = t.workflow_step_id WHERE ws.auto_archive_after_hours > 0 diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go new file mode 100644 index 0000000000..8661786ac3 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/task/models" +) + +// CreateTaskEnvironment creates a new task environment record. +func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + if env.ID == "" { + env.ID = uuid.New().String() + } + now := time.Now().UTC() + env.CreatedAt = now + env.UpdatedAt = now + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_environments ( + id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), + env.ID, env.TaskID, env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, + env.AgentExecutionID, env.ControlPort, string(env.Status), + env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, + env.ContainerID, env.SandboxID, + env.CreatedAt, env.UpdatedAt, + ) + return err +} + +// GetTaskEnvironment retrieves a task environment by ID. +func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) { + env := &models.TaskEnvironment{} + var status string + + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, + created_at, updated_at + FROM task_environments WHERE id = ? + `), id).Scan( + &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, + &env.AgentExecutionID, &env.ControlPort, &status, + &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, + &env.ContainerID, &env.SandboxID, + &env.CreatedAt, &env.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("task environment not found: %s", id) + } + if err != nil { + return nil, err + } + env.Status = models.TaskEnvironmentStatus(status) + return env, nil +} + +// GetTaskEnvironmentByTaskID retrieves the active task environment for a task. +func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { + env := &models.TaskEnvironment{} + var status string + + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + agent_execution_id, control_port, status, + worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, + created_at, updated_at + FROM task_environments WHERE task_id = ? ORDER BY created_at DESC LIMIT 1 + `), taskID).Scan( + &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, + &env.AgentExecutionID, &env.ControlPort, &status, + &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, + &env.ContainerID, &env.SandboxID, + &env.CreatedAt, &env.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil // No environment yet — not an error + } + if err != nil { + return nil, err + } + env.Status = models.TaskEnvironmentStatus(status) + return env, nil +} + +// UpdateTaskEnvironment updates an existing task environment. +func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { + env.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_environments SET + repository_id = ?, executor_type = ?, executor_id = ?, executor_profile_id = ?, + agent_execution_id = ?, control_port = ?, status = ?, + worktree_id = ?, worktree_path = ?, worktree_branch = ?, workspace_path = ?, + container_id = ?, sandbox_id = ?, + updated_at = ? + WHERE id = ? + `), + env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, + env.AgentExecutionID, env.ControlPort, string(env.Status), + env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, + env.ContainerID, env.SandboxID, + env.UpdatedAt, + env.ID, + ) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment not found: %s", env.ID) + } + return nil +} + +// DeleteTaskEnvironment deletes a task environment by ID. +func (r *Repository) DeleteTaskEnvironment(ctx context.Context, id string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environments WHERE id = ? + `), id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task environment not found: %s", id) + } + return nil +} + +// DeleteTaskEnvironmentsByTask deletes all task environments for a given task. +func (r *Repository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM task_environments WHERE task_id = ? + `), taskID) + return err +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index a17bb14437..740c4e1197 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -95,18 +95,19 @@ func validateExecutorConfig(config map[string]string) error { // Repos holds the repository sub-interfaces used by the task service. type Repos struct { - Workspaces repository.WorkspaceRepository - Tasks repository.TaskRepository - TaskRepos repository.TaskRepoRepository - Workflows repository.WorkflowRepository - Messages repository.MessageRepository - Turns repository.TurnRepository - Sessions repository.SessionRepository - GitSnapshots repository.GitSnapshotRepository - RepoEntities repository.RepositoryEntityRepository - Executors repository.ExecutorRepository - Environments repository.EnvironmentRepository - Reviews repository.ReviewRepository + Workspaces repository.WorkspaceRepository + Tasks repository.TaskRepository + TaskRepos repository.TaskRepoRepository + Workflows repository.WorkflowRepository + Messages repository.MessageRepository + Turns repository.TurnRepository + Sessions repository.SessionRepository + GitSnapshots repository.GitSnapshotRepository + RepoEntities repository.RepositoryEntityRepository + Executors repository.ExecutorRepository + Environments repository.EnvironmentRepository + TaskEnvironments repository.TaskEnvironmentRepository + Reviews repository.ReviewRepository } // Service provides task business logic @@ -122,6 +123,7 @@ type Service struct { repoEntities repository.RepositoryEntityRepository executors repository.ExecutorRepository environments repository.EnvironmentRepository + taskEnvironments repository.TaskEnvironmentRepository reviews repository.ReviewRepository eventBus bus.EventBus logger *logger.Logger @@ -138,21 +140,22 @@ type Service struct { // NewService creates a new task service func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service { return &Service{ - workspaces: repos.Workspaces, - tasks: repos.Tasks, - taskRepos: repos.TaskRepos, - workflows: repos.Workflows, - messages: repos.Messages, - turns: repos.Turns, - sessions: repos.Sessions, - gitSnapshots: repos.GitSnapshots, - repoEntities: repos.RepoEntities, - executors: repos.Executors, - environments: repos.Environments, - reviews: repos.Reviews, - eventBus: eventBus, - logger: log, - discoveryConfig: discoveryConfig, + workspaces: repos.Workspaces, + tasks: repos.Tasks, + taskRepos: repos.TaskRepos, + workflows: repos.Workflows, + messages: repos.Messages, + turns: repos.Turns, + sessions: repos.Sessions, + gitSnapshots: repos.GitSnapshots, + repoEntities: repos.RepoEntities, + executors: repos.Executors, + environments: repos.Environments, + taskEnvironments: repos.TaskEnvironments, + reviews: repos.Reviews, + eventBus: eventBus, + logger: log, + discoveryConfig: discoveryConfig, } } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index a95053294c..2575861441 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -54,6 +54,10 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * } } + if task.ParentID != "" { + data["parent_id"] = task.ParentID + } + if task.ArchivedAt != nil { data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) } diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 34444218f8..e5009896ef 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -33,6 +33,7 @@ type CreateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` PlanMode bool `json:"plan_mode,omitempty"` IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat + ParentID string `json:"parent_id,omitempty"` } // UpdateTaskRequest contains the data for updating a task diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go index bc9e987600..28fab9d33b 100644 --- a/apps/backend/internal/task/service/service_sessions.go +++ b/apps/backend/internal/task/service/service_sessions.go @@ -51,18 +51,6 @@ func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error return nil } -// MoveSessionToStep moves a session to a different workflow step. -func (s *Service) MoveSessionToStep(ctx context.Context, sessionID string, stepID string) error { - if err := s.sessions.UpdateSessionWorkflowStep(ctx, sessionID, stepID); err != nil { - s.logger.Error("failed to move session to step", - zap.String("session_id", sessionID), - zap.String("step_id", stepID), - zap.Error(err)) - return err - } - return nil -} - // UpdateSessionReviewStatus updates the review status of a session. func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error { if err := s.sessions.UpdateSessionReviewStatus(ctx, sessionID, status); err != nil { diff --git a/apps/backend/internal/task/service/service_task_environments.go b/apps/backend/internal/task/service/service_task_environments.go new file mode 100644 index 0000000000..dd2e3de239 --- /dev/null +++ b/apps/backend/internal/task/service/service_task_environments.go @@ -0,0 +1,13 @@ +package service + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" +) + +// GetTaskEnvironmentByTaskID returns the active task environment for a task. +// Returns nil if no environment exists yet. +func (s *Service) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { + return s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 72f378bdbb..62c69696ba 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -73,6 +73,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode Position: req.Position, Metadata: req.Metadata, IsEphemeral: req.IsEphemeral, + ParentID: req.ParentID, } if err := s.tasks.CreateTask(ctx, task); err != nil { diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 4922cdd079..142297b59c 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -39,12 +39,18 @@ func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*Approv } result.Session = session + // Get the task to find its current workflow step + task, err := s.tasks.GetTask(ctx, session.TaskID) + if err != nil { + return nil, fmt.Errorf("failed to get task: %w", err) + } + // Get the current workflow step to check for transition targets - if session.WorkflowStepID != nil && s.workflowStepGetter != nil { - step, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID) + if task.WorkflowStepID != "" && s.workflowStepGetter != nil { + step, err := s.workflowStepGetter.GetStep(ctx, task.WorkflowStepID) if err != nil { s.logger.Warn("failed to get workflow step for approval transition", - zap.String("workflow_step_id", *session.WorkflowStepID), + zap.String("workflow_step_id", task.WorkflowStepID), zap.Error(err)) } else { s.applyApprovalStepTransition(ctx, sessionID, step, result) @@ -66,15 +72,7 @@ func (s *Service) applyApprovalStepTransition(ctx context.Context, sessionID str return } - if err := s.sessions.UpdateSessionWorkflowStep(ctx, sessionID, newStepID); err != nil { - s.logger.Error("failed to move session to next step after approval", - zap.String("session_id", sessionID), - zap.String("step_id", newStepID), - zap.Error(err)) - return - } - - // Also move the task to the new step + // Move the task to the new step if task, err := s.tasks.GetTask(ctx, result.Session.TaskID); err != nil { s.logger.Error("failed to get task for approval transition", zap.String("task_id", result.Session.TaskID), @@ -243,7 +241,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo return nil, err } - sessionID := s.syncActiveSessionWorkflowStep(ctx, id, task.WorkflowStepID) + // Resolve active session for the task.moved event (needed for on_exit/on_enter). + sessionID := "" + if activeSession := s.resolvePrimaryOrActiveSession(ctx, id); activeSession != nil { + sessionID = activeSession.ID + } // Publish state_changed event if state changed, otherwise just updated if oldState != task.State { @@ -297,33 +299,25 @@ func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStep return nil } -// syncActiveSessionWorkflowStep updates the active session's workflow_step_id to match the -// task's new step when they differ. Failures are logged but do not abort the move. -// Returns the active session ID (empty if none exists). -func (s *Service) syncActiveSessionWorkflowStep(ctx context.Context, taskID, workflowStepID string) string { - if workflowStepID == "" { - return "" +// resolvePrimaryOrActiveSession returns the primary session if it is in an active +// state, otherwise falls back to the most recently started active session. +func (s *Service) resolvePrimaryOrActiveSession(ctx context.Context, taskID string) *models.TaskSession { + primary, _ := s.sessions.GetPrimarySessionByTaskID(ctx, taskID) + if primary != nil && isSessionActive(primary.State) { + return primary } - activeSession, err := s.sessions.GetActiveTaskSessionByTaskID(ctx, taskID) - if err != nil || activeSession == nil { - return "" + active, err := s.sessions.GetActiveTaskSessionByTaskID(ctx, taskID) + if err != nil || active == nil { + return nil } - if activeSession.WorkflowStepID != nil && *activeSession.WorkflowStepID == workflowStepID { - return activeSession.ID - } - if err := s.sessions.UpdateSessionWorkflowStep(ctx, activeSession.ID, workflowStepID); err != nil { - s.logger.Warn("failed to update session workflow step after task move", - zap.String("task_id", taskID), - zap.String("session_id", activeSession.ID), - zap.String("workflow_step_id", workflowStepID), - zap.Error(err)) - return activeSession.ID - } - s.logger.Info("updated session workflow step to match moved task", - zap.String("task_id", taskID), - zap.String("session_id", activeSession.ID), - zap.String("workflow_step_id", workflowStepID)) - return activeSession.ID + return active +} + +func isSessionActive(state models.TaskSessionState) bool { + return state == models.TaskSessionStateCreated || + state == models.TaskSessionStateStarting || + state == models.TaskSessionStateRunning || + state == models.TaskSessionStateWaitingForInput } // CountTasksByWorkflow returns the number of tasks in a workflow @@ -374,19 +368,6 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err) } - // Update active session's workflow_step_id - activeSession, err := s.sessions.GetActiveTaskSessionByTaskID(ctx, task.ID) - if err == nil && activeSession != nil { - if activeSession.WorkflowStepID == nil || *activeSession.WorkflowStepID != targetStepID { - if err := s.sessions.UpdateSessionWorkflowStep(ctx, activeSession.ID, targetStepID); err != nil { - s.logger.Warn("failed to update session workflow step during bulk move", - zap.String("task_id", task.ID), - zap.String("session_id", activeSession.ID), - zap.Error(err)) - } - } - } - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) } diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go index 9468fc347c..a065f879d7 100644 --- a/apps/backend/internal/utility/controller/controller.go +++ b/apps/backend/internal/utility/controller/controller.go @@ -95,16 +95,17 @@ func (c *Controller) GetTemplateVariables(ctx context.Context) *dto.TemplateVari // If defaults is provided and the utility agent has empty agent_id/model, the defaults are used. func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings) (*service.PromptRequest, error) { tmplCtx := &template.Context{ - GitDiff: req.GitDiff, - CommitLog: req.CommitLog, - ChangedFiles: req.ChangedFiles, - DiffSummary: req.DiffSummary, - BranchName: req.BranchName, - BaseBranch: req.BaseBranch, - TaskTitle: req.TaskTitle, - TaskDescription: req.TaskDescription, - SessionID: req.SessionID, - UserPrompt: req.UserPrompt, + GitDiff: req.GitDiff, + CommitLog: req.CommitLog, + ChangedFiles: req.ChangedFiles, + DiffSummary: req.DiffSummary, + BranchName: req.BranchName, + BaseBranch: req.BaseBranch, + TaskTitle: req.TaskTitle, + TaskDescription: req.TaskDescription, + SessionID: req.SessionID, + UserPrompt: req.UserPrompt, + ConversationHistory: req.ConversationHistory, } return c.svc.PreparePromptRequest(ctx, req.UtilityAgentID, tmplCtx, defaults) } diff --git a/apps/backend/internal/utility/dto/dto.go b/apps/backend/internal/utility/dto/dto.go index 4719bb5940..37be4c277a 100644 --- a/apps/backend/internal/utility/dto/dto.go +++ b/apps/backend/internal/utility/dto/dto.go @@ -120,15 +120,16 @@ type ExecutePromptRequest struct { SessionID string `json:"session_id"` // Context variables for template resolution. - GitDiff string `json:"git_diff,omitempty"` - CommitLog string `json:"commit_log,omitempty"` - ChangedFiles string `json:"changed_files,omitempty"` - DiffSummary string `json:"diff_summary,omitempty"` - BranchName string `json:"branch_name,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - TaskTitle string `json:"task_title,omitempty"` - TaskDescription string `json:"task_description,omitempty"` - UserPrompt string `json:"user_prompt,omitempty"` + GitDiff string `json:"git_diff,omitempty"` + CommitLog string `json:"commit_log,omitempty"` + ChangedFiles string `json:"changed_files,omitempty"` + DiffSummary string `json:"diff_summary,omitempty"` + BranchName string `json:"branch_name,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + TaskTitle string `json:"task_title,omitempty"` + TaskDescription string `json:"task_description,omitempty"` + UserPrompt string `json:"user_prompt,omitempty"` + ConversationHistory string `json:"conversation_history,omitempty"` } // ExecutePromptResponse is the response from executing a utility prompt. diff --git a/apps/backend/internal/utility/store/builtins.go b/apps/backend/internal/utility/store/builtins.go index a196843adb..5c9070f38d 100644 --- a/apps/backend/internal/utility/store/builtins.go +++ b/apps/backend/internal/utility/store/builtins.go @@ -37,6 +37,7 @@ var builtinDefs = []builtinDef{ {"builtin-pr-title", "pr-title", "Generate a PR title based on commits and changes", "pr-title"}, {"builtin-pr-description", "pr-description", "Generate a PR description based on commits and changes", "pr-description"}, {"builtin-enhance-prompt", "enhance-prompt", "Enhance and expand a user prompt with context and clarity", "enhance-prompt"}, + {"builtin-summarize-session", "summarize-session", "Summarize a session conversation for context handover", "summarize-session"}, } // getBuiltinAgents returns the predefined built-in utility agents with prompts from embedded files. diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go index 5196de9188..0617e09f49 100644 --- a/apps/backend/internal/utility/template/engine.go +++ b/apps/backend/internal/utility/template/engine.go @@ -26,6 +26,9 @@ type Context struct { // User input UserPrompt string // User's original prompt text (for enhance-prompt) + // Conversation + ConversationHistory string // Formatted conversation transcript (for summarize-session) + // Custom key-value pairs for extensibility Custom map[string]string } @@ -65,17 +68,18 @@ func (e *Engine) Resolve(promptTemplate string, ctx *Context) (string, error) { // buildTemplateData creates the data map for template execution. func (e *Engine) buildTemplateData(ctx *Context) map[string]string { data := map[string]string{ - "GitDiff": ctx.GitDiff, - "CommitLog": ctx.CommitLog, - "ChangedFiles": ctx.ChangedFiles, - "DiffSummary": ctx.DiffSummary, - "BranchName": ctx.BranchName, - "BaseBranch": ctx.BaseBranch, - "TaskTitle": ctx.TaskTitle, - "TaskDescription": ctx.TaskDescription, - "SessionID": ctx.SessionID, - "WorkspacePath": ctx.WorkspacePath, - "UserPrompt": ctx.UserPrompt, + "GitDiff": ctx.GitDiff, + "CommitLog": ctx.CommitLog, + "ChangedFiles": ctx.ChangedFiles, + "DiffSummary": ctx.DiffSummary, + "BranchName": ctx.BranchName, + "BaseBranch": ctx.BaseBranch, + "TaskTitle": ctx.TaskTitle, + "TaskDescription": ctx.TaskDescription, + "SessionID": ctx.SessionID, + "WorkspacePath": ctx.WorkspacePath, + "UserPrompt": ctx.UserPrompt, + "ConversationHistory": ctx.ConversationHistory, } // Add custom key-value pairs @@ -100,6 +104,7 @@ func (e *Engine) AvailableVariables() []VariableInfo { {Name: "SessionID", Description: "Current session ID", Example: "sess_abc123", Category: "session"}, {Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"}, {Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"}, + {Name: "ConversationHistory", Description: "Formatted conversation transcript", Example: "User: Fix the bug\nAgent: I'll look into...", Category: "session"}, } } diff --git a/apps/backend/internal/worktree/config.go b/apps/backend/internal/worktree/config.go index 5e85b8fe62..8c22461fe6 100644 --- a/apps/backend/internal/worktree/config.go +++ b/apps/backend/internal/worktree/config.go @@ -20,6 +20,12 @@ type Config struct { // Default: ~/.kandev/worktrees BasePath string `mapstructure:"base_path"` + // TasksBasePath is the base directory for per-task worktree storage. + // Each task gets a subdirectory containing one repo worktree (future: multiple). + // Supports ~ expansion for home directory. + // Default: ~/.kandev/tasks + TasksBasePath string `mapstructure:"tasks_base_path"` + // BranchPrefix is the prefix used for worktree branch names. // Default: feature/ BranchPrefix string `mapstructure:"branch_prefix"` @@ -51,6 +57,13 @@ func (c *Config) SetDataDirFallback(dataDir string) { } } +// SetTasksBasePathFallback sets the TasksBasePath from the data directory if not already configured. +func (c *Config) SetTasksBasePathFallback(dataDir string) { + if c.TasksBasePath == "" && dataDir != "" { + c.TasksBasePath = filepath.Join(dataDir, "tasks") + } +} + // ExpandedBasePath returns the base path with ~ expanded to the user's home directory. func (c *Config) ExpandedBasePath() (string, error) { path := c.BasePath @@ -73,6 +86,29 @@ func (c *Config) WorktreePath(worktreeID string) (string, error) { return filepath.Join(basePath, worktreeID), nil } +// ExpandedTasksBasePath returns the tasks base path with ~ expanded to the user's home directory. +func (c *Config) ExpandedTasksBasePath() (string, error) { + path := c.TasksBasePath + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, path[2:]) + } + return path, nil +} + +// TaskWorktreePath returns the full path for a worktree inside a task directory. +// Format: {tasksBase}/{taskDirName}/{repoName} +func (c *Config) TaskWorktreePath(taskDirName, repoName string) (string, error) { + basePath, err := c.ExpandedTasksBasePath() + if err != nil { + return "", err + } + return filepath.Join(basePath, taskDirName, repoName), nil +} + // BranchName returns the branch name for a given task ID and suffix. // Format: {prefix}{taskID}-{suffix} e.g. feature/abc123-xyz func (c *Config) BranchName(taskID, suffix string) string { diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go index 814a8e452e..6e90fa1871 100644 --- a/apps/backend/internal/worktree/manager.go +++ b/apps/backend/internal/worktree/manager.go @@ -92,6 +92,17 @@ func NewManager(cfg Config, store Store, log *logger.Logger) (*Manager, error) { return nil, fmt.Errorf("failed to create worktree base directory: %w", err) } + // Ensure tasks base directory exists (if configured) + if cfg.TasksBasePath != "" { + tasksBase, err := cfg.ExpandedTasksBasePath() + if err != nil { + return nil, fmt.Errorf("failed to expand tasks base path: %w", err) + } + if err := os.MkdirAll(tasksBase, 0755); err != nil { + return nil, fmt.Errorf("failed to create tasks base directory: %w", err) + } + } + fetchTimeout := defaultGitFetchTimeout if cfg.FetchTimeoutSeconds > 0 { fetchTimeout = time.Duration(cfg.FetchTimeoutSeconds) * time.Second @@ -244,6 +255,11 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef) } + // Task-dir mode: place worktree inside ~/.kandev/tasks/{taskDir}/{repo}/ + if req.TaskDirName != "" && req.RepoName != "" { + return m.createInTaskDir(ctx, req, baseRef) + } + return m.createWorktree(ctx, req, baseRef) } @@ -298,6 +314,62 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef return wt, nil } +// createInTaskDir creates a worktree inside the task directory structure: +// ~/.kandev/tasks/{taskDirName}/{repoName}/ +func (m *Manager) createInTaskDir(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) { + worktreePath, err := m.config.TaskWorktreePath(req.TaskDirName, req.RepoName) + if err != nil { + return nil, fmt.Errorf("failed to get task worktree path: %w", err) + } + + // Ensure parent task directory exists + taskDir := filepath.Dir(worktreePath) + if err := os.MkdirAll(taskDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create task directory: %w", err) + } + + _, branchName := m.buildWorktreeNames(req) + startPoint := baseRef + + var fetchResult *FetchBranchResult + if req.CheckoutBranch != "" { + fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch) + if err != nil { + return nil, err + } + startPoint = req.CheckoutBranch + } + + worktreeID, branchName, err := m.addWorktreeForBranch(ctx, req, worktreePath, branchName, startPoint, baseRef) + if err != nil { + return nil, err + } + + wt := m.buildWorktreeRecord(worktreeID, req, worktreePath, branchName) + if fetchResult != nil { + wt.FetchWarning = fetchResult.Warning + wt.FetchWarningDetail = fetchResult.WarningDetail + } + + if err := m.persistAndCacheWorktree(ctx, wt, req, worktreePath); err != nil { + return nil, err + } + + if err := m.runWorktreeSetupScript(ctx, wt, req.RepositoryPath); err != nil { + return nil, err + } + + m.logger.Info("created worktree in task directory", + zap.String("session_id", req.SessionID), + zap.String("task_id", req.TaskID), + zap.String("task_dir", req.TaskDirName), + zap.String("repo_name", req.RepoName), + zap.String("path", worktreePath), + zap.String("branch", wt.Branch)) + + return wt, nil +} + // addWorktreeForBranch creates the git worktree, trying the checkout branch directly first // and falling back to a suffixed branch if the checkout branch is already in use. func (m *Manager) addWorktreeForBranch(ctx context.Context, req CreateRequest, worktreePath, fallbackBranch, startPoint, baseRef string) (string, string, error) { @@ -1021,9 +1093,59 @@ func (m *Manager) Reconcile(ctx context.Context, activeTasks []string) error { } } + // Also scan the tasks directory for orphaned task directories + m.reconcileTasksDir(ctx) + return nil } +// reconcileTasksDir scans ~/.kandev/tasks/ for orphaned task directories +// whose worktrees are no longer referenced by the store. +func (m *Manager) reconcileTasksDir(ctx context.Context) { + tasksBase, err := m.config.ExpandedTasksBasePath() + if err != nil || tasksBase == "" { + return + } + + entries, err := os.ReadDir(tasksBase) + if err != nil { + if !os.IsNotExist(err) { + m.logger.Debug("failed to read tasks directory for reconciliation", + zap.String("path", tasksBase), zap.Error(err)) + } + return + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + taskDirPath := filepath.Join(tasksBase, entry.Name()) + if m.isOrphanedTaskDir(ctx, taskDirPath) { + m.logger.Info("cleaning up orphaned task directory", + zap.String("path", taskDirPath)) + if err := os.RemoveAll(taskDirPath); err != nil { + m.logger.Warn("failed to remove orphaned task directory", + zap.String("path", taskDirPath), zap.Error(err)) + } + } + } +} + +// isOrphanedTaskDir checks if a task directory contains no valid worktrees. +func (m *Manager) isOrphanedTaskDir(ctx context.Context, taskDirPath string) bool { + subEntries, err := os.ReadDir(taskDirPath) + if err != nil { + return true // Can't read it, treat as orphaned + } + for _, sub := range subEntries { + if sub.IsDir() && m.IsValid(filepath.Join(taskDirPath, sub.Name())) { + return false // At least one valid worktree + } + } + return true +} + // isGitRepo checks if a path is a Git repository. func (m *Manager) isGitRepo(path string) bool { gitDir := filepath.Join(path, ".git") diff --git a/apps/backend/internal/worktree/provider.go b/apps/backend/internal/worktree/provider.go index b9289e3068..d1d8ab5105 100644 --- a/apps/backend/internal/worktree/provider.go +++ b/apps/backend/internal/worktree/provider.go @@ -21,6 +21,7 @@ func Provide(writer, reader *sqlx.DB, cfg *config.Config, log *logger.Logger) (* PullTimeoutSeconds: cfg.Worktree.PullTimeoutSeconds, } wtCfg.SetDataDirFallback(cfg.ResolvedHomeDir()) + wtCfg.SetTasksBasePathFallback(cfg.ResolvedHomeDir()) manager, err := NewManager(wtCfg, store, log) if err != nil { return nil, nil, err diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index 8affc92e18..3ff00e3d49 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -113,6 +113,15 @@ type CreateRequest struct { // If provided and valid, the existing worktree is returned instead of creating a new one. WorktreeID string + // TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12"). + // When set together with RepoName, the worktree is placed at + // ~/.kandev/tasks/{TaskDirName}/{RepoName}/ instead of ~/.kandev/worktrees/. + TaskDirName string + + // RepoName is the repository name used as subdirectory inside the task directory. + // Only used when TaskDirName is also set. + RepoName string + // OnSyncProgress receives progress updates for pre-worktree branch sync. OnSyncProgress SyncProgressCallback } diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 2cbf279b7c..e3879ed29e 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -74,7 +74,8 @@ type Task struct { StartedAt *time.Time `json:"started_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` - IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks } // TaskRepositoryInput for creating/updating task repositories diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 333a177379..281be52d62 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -78,6 +78,9 @@ const ( ActionSessionLaunch = "session.launch" ActionSessionRecover = "session.recover" ActionSessionResetContext = "session.reset_context" + ActionSessionStop = "session.stop" + ActionSessionDelete = "session.delete" + ActionSessionSetPrimary = "session.set_primary" // Agent actions ActionAgentList = "agent.list" diff --git a/apps/web/app/t/[taskId]/page.tsx b/apps/web/app/t/[taskId]/page.tsx new file mode 100644 index 0000000000..afbb770132 --- /dev/null +++ b/apps/web/app/t/[taskId]/page.tsx @@ -0,0 +1,56 @@ +import { StateHydrator } from "@/components/state-hydrator"; +import { readLayoutDefaults } from "@/lib/layout/read-layout-defaults"; +import { TaskPageContent } from "@/components/task/task-page-content"; +import { + type FetchedSessionData, + fetchSessionDataForTask, + extractInitialRepositories, + extractInitialScripts, +} from "@/lib/ssr/session-page-state"; + +export default async function TaskPage({ + params, + searchParams, +}: { + params: Promise<{ taskId: string }>; + searchParams: Promise<{ layout?: string }>; +}) { + let fetchedData: FetchedSessionData | null = null; + const defaultLayouts = await readLayoutDefaults(); + const { layout: initialLayout } = await searchParams; + const { taskId } = await params; + + try { + fetchedData = await fetchSessionDataForTask(taskId); + } catch (error) { + console.warn( + "Could not SSR task page (client will load via WebSocket):", + error instanceof Error ? error.message : String(error), + ); + } + + const { task, sessionId, initialState, initialTerminals } = fetchedData ?? { + task: null, + sessionId: null, + initialState: null, + initialTerminals: [], + }; + + return ( + <> + {initialState ? ( + + ) : null} + + + ); +} diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index e8eb0406d9..ee9b915d6a 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -32,7 +32,6 @@ function TitleCell({ repoMap: Map; }) { const task = row.original; - const sessionId = task.primary_session_id; const isArchived = !!task.archived_at; const repoName = task.repositories?.[0] ? repoMap.get(task.repositories[0].repository_id) @@ -40,13 +39,9 @@ function TitleCell({ return (
- {sessionId ? ( - - {task.title} - - ) : ( - {task.title} - )} + + {task.title} + {isArchived && ( Promise; deletingTaskId: string | null; router: ReturnType; - toast: ReturnType["toast"]; activeWorkflowId: string | null; }) { const pageCount = useMemo( @@ -334,16 +332,9 @@ function useTasksPageComputed({ ); const handleRowClick = useCallback( (task: Task) => { - if (task.primary_session_id) { - router.push(`/s/${task.primary_session_id}`); - } else { - toast({ - title: "No session available", - description: "This task has no associated session yet.", - }); - } + router.push(`/t/${task.id}`); }, - [router, toast], + [router], ); const defaultWorkflow = activeWorkflowId ? workflows.find((w) => w.id === activeWorkflowId) @@ -360,7 +351,6 @@ function useTasksPageSetup(props: TasksPageClientProps) { activeWorkflowId, repositories: storeRepositories, } = useKanbanDisplaySettings(); - const { toast } = useToast(); const viewState = useTasksPageViewState({ initialWorkflows: props.initialWorkflows, initialSteps: props.initialSteps, @@ -396,7 +386,6 @@ function useTasksPageSetup(props: TasksPageClientProps) { handleDelete: ops.handleDelete, deletingTaskId: ops.deletingTaskId, router, - toast, activeWorkflowId, }); return { ...viewState, ...ops, ...computed, activeWorkspaceId, debouncedQuery }; diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 6775733490..82c1f9a717 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -142,7 +142,6 @@ export const Combobox = memo(function Combobox({ { const map = new Map(); @@ -362,10 +361,9 @@ function useCommandPanelHandlers( const handleTaskSelect = useCallback( (task: Task) => { setOpen(false); - if (task.primary_session_id) router.push(linkToSession(task.primary_session_id)); - else toast({ title: "This task has no active session", variant: "default" }); + router.push(linkToTask(task.id)); }, - [setOpen, router, toast], + [setOpen, router], ); const handleFileSelect = useCallback( diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 7ea3d08477..76f0262c0f 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -17,7 +17,7 @@ import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/do import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { HomepageCommands } from "./homepage-commands"; -import { linkToSession } from "@/lib/links"; +import { linkToTask } from "@/lib/links"; import { AlertDialog, AlertDialogContent, @@ -99,8 +99,8 @@ function useMoveErrorState(router: ReturnType) { }, []); const handleGoToTask = useCallback(() => { - if (moveError?.sessionId) { - router.push(linkToSession(moveError.sessionId)); + if (moveError?.taskId) { + router.push(linkToTask(moveError.taskId)); } setMoveError(null); }, [moveError, router]); @@ -134,7 +134,7 @@ function useKanbanBoardStore() { interface KanbanBoardProps { onPreviewTask?: (task: Task) => void; - onOpenTask?: (task: Task, sessionId: string) => void; + onOpenTask?: (task: Task) => void; } function useKanbanBoardHooks( @@ -158,18 +158,12 @@ function useKanbanBoardHooks( deletingTaskId, archivingTaskId, } = useKanbanActions({ workspaceState, workflowsState }); - const { - enablePreviewOnClick, - userSettings, - commitSettings, - activeSteps, - isMounted, - setTaskSessionAvailability, - } = useKanbanData({ - onWorkspaceChange: handleWorkspaceChange, - onWorkflowChange: handleWorkflowChange, - searchQuery, - }); + const { enablePreviewOnClick, userSettings, commitSettings, activeSteps, isMounted } = + useKanbanData({ + onWorkspaceChange: handleWorkspaceChange, + onWorkflowChange: handleWorkflowChange, + searchQuery, + }); return { isDialogOpen, editingTask, @@ -188,7 +182,6 @@ function useKanbanBoardHooks( commitSettings, activeSteps, isMounted, - setTaskSessionAvailability, }; } @@ -217,9 +210,6 @@ function useKanbanBoardSetup( isMobile, onPreviewTask, onOpenTask, - setEditingTask: hooks.setEditingTask, - setIsDialogOpen: hooks.setIsDialogOpen, - setTaskSessionAvailability: hooks.setTaskSessionAvailability, }); // Mobile bottom sheet: intercept card clicks to show task info first @@ -434,7 +424,7 @@ function ApprovalWarningDialog({ Dismiss - {moveError?.sessionId && ( + {moveError?.taskId && ( Go to Task )} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 51b5a7b736..19148f8584 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -3,7 +3,13 @@ import { useMemo, useState } from "react"; import { useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { IconDots, IconArrowsMaximize, IconLoader, IconAlertCircle } from "@tabler/icons-react"; +import { + IconDots, + IconArrowsMaximize, + IconLoader, + IconAlertCircle, + IconSubtask, +} from "@tabler/icons-react"; import { Card, CardContent } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; import { @@ -43,7 +49,6 @@ export interface Task { description?: string; position?: number; repositoryId?: string; - hasSession?: boolean; // Workflow fields sessionCount?: number | null; primarySessionId?: string | null; @@ -52,6 +57,7 @@ export interface Task { primaryExecutorType?: string | null; primaryExecutorName?: string | null; isRemoteExecutor?: boolean; + parentTaskId?: string | null; updatedAt?: string; } @@ -95,7 +101,12 @@ function KanbanCardBody({

)}
-

{task.title}

+

+ {task.title} +

@@ -113,32 +124,53 @@ function KanbanCardBody({ {task.description}

)} - {((task.sessionCount && task.sessionCount > 1) || - task.reviewStatus === "changes_requested" || - task.reviewStatus === "pending") && ( -
- {task.sessionCount && task.sessionCount > 1 && ( - - {task.sessionCount} sessions - - )} - {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( -
- - Approval Required -
- )} - {task.reviewStatus === "changes_requested" && ( - - Changes Requested - - )} + + + ); +} + +function KanbanCardBadges({ task }: { task: Task }) { + const parentTitle = useAppStore((s) => { + if (!task.parentTaskId) return null; + return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; + }); + + const showRow = + (task.sessionCount && task.sessionCount > 1) || + task.reviewStatus === "changes_requested" || + task.reviewStatus === "pending" || + task.parentTaskId; + + if (!showRow) return null; + + return ( +
+ {task.parentTaskId && ( + + + {parentTitle ?? "Subtask"} + + )} + {task.sessionCount && task.sessionCount > 1 && ( + + {task.sessionCount} sessions + + )} + {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( +
+ + Approval Required
)} - + {task.reviewStatus === "changes_requested" && ( + + Changes Requested + + )} +
); } @@ -184,9 +216,7 @@ function KanbanCardActions({ const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); const statusIcon = getTaskStateIcon(task.state, "h-4 w-4"); const hasKnownSession = - Boolean(task.primarySessionId) || - Boolean(task.sessionCount && task.sessionCount > 0) || - task.hasSession === true; + Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); return (
diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 67d4f8ba5f..c835c1d02b 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -12,7 +12,7 @@ import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; import type { KanbanState } from "@/lib/state/slices"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; -import { linkToSession } from "@/lib/links"; +import { linkToTask } from "@/lib/links"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; @@ -123,6 +123,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { description: task.description, position: task.position, repositoryId: task.repositoryId, + primarySessionId: task.primarySessionId, }; }, [selectedTaskId, kanbanTasks]); @@ -147,8 +148,8 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { }, [isOpen, selectedTaskId, selectedTaskSessionId, isLoading]); const handleNavigateToTask = useCallback( - (task: Task, sessionId: string) => { - router.push(linkToSession(sessionId)); + (task: Task) => { + router.push(linkToTask(task.id)); }, [router], ); @@ -170,7 +171,9 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { const handleResizeMouseDown = useResizeHandler(isResizingRef, previewWidthPx, updatePreviewWidth); - const activeSessionId = selectedTaskId ? selectedTaskSessionId : null; + const activeSessionId = selectedTaskId + ? (selectedTask?.primarySessionId ?? selectedTaskSessionId) + : null; // On mobile, skip the preview panel entirely — card clicks navigate directly if (isMobile) { @@ -229,7 +232,7 @@ type PreviewLayoutProps = { selectedTask: Task | null; activeSessionId: string | null; onPreviewTask: (task: Task) => void; - onNavigateToTask: (task: Task, sessionId: string) => void; + onNavigateToTask: (task: Task) => void; onClose: () => void; onResizeMouseDown: (e: React.MouseEvent) => void; }; @@ -267,9 +270,7 @@ function FloatingPreviewLayout({ task={selectedTask} sessionId={activeSessionId} onClose={onClose} - onMaximize={ - activeSessionId ? (task) => onNavigateToTask(task, activeSessionId) : undefined - } + onMaximize={(task) => onNavigateToTask(task)} />
@@ -304,9 +305,7 @@ function InlinePreviewLayout({ task={selectedTask} sessionId={activeSessionId} onClose={onClose} - onMaximize={ - activeSessionId ? (task) => onNavigateToTask(task, activeSessionId) : undefined - } + onMaximize={(task) => onNavigateToTask(task)} />
diff --git a/apps/web/components/kanban/graph2-step-node.tsx b/apps/web/components/kanban/graph2-step-node.tsx index d40ac30e36..8ee43c544f 100644 --- a/apps/web/components/kanban/graph2-step-node.tsx +++ b/apps/web/components/kanban/graph2-step-node.tsx @@ -10,7 +10,7 @@ import { } from "@tabler/icons-react"; import { cn } from "@kandev/ui/lib/utils"; import { getTaskStateIcon } from "@/lib/ui/state-icons"; -import { linkToSession } from "@/lib/links"; +import { linkToTask } from "@/lib/links"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; @@ -94,7 +94,6 @@ export function Graph2StepNode({ hasPrev, hasNext, onMoveTask, - onPreviewTask, prevStepId, nextStepId, isMoving, @@ -109,11 +108,7 @@ export function Graph2StepNode({ const running = isRunningState(task.state); const handleClick = () => { - if (task.primarySessionId) { - router.push(linkToSession(task.primarySessionId)); - } else { - onPreviewTask(task); - } + router.push(linkToTask(task.id)); }; return ( diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 363d7a7e86..479b2b341b 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -41,16 +41,10 @@ export type SwimlaneKanbanContentProps = { type SwimlaneKanbanDndOptions = { tasks: Task[]; workflowId: string; - useTouchSensors: boolean; onMoveError?: (error: MoveTaskError) => void; }; -function useSwimlaneKanbanDnd({ - tasks, - workflowId, - useTouchSensors, - onMoveError, -}: SwimlaneKanbanDndOptions) { +function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanbanDndOptions) { const store = useAppStoreApi(); const { moveTaskById } = useTaskActions(); const [activeTaskId, setActiveTaskId] = useState(null); @@ -136,15 +130,8 @@ function useSwimlaneKanbanDnd({ [tasks, activeTaskId], ); - // Touch-only sensors for mobile/tablet (PointerSensor conflicts with touch scroll) - const touchSensors = useSensors( - useSensor(TouchSensor, { - activationConstraint: { delay: 250, tolerance: 5 }, - }), - ); - return { - sensors: useTouchSensors ? touchSensors : sensors, + sensors, handleDragStart, handleDragEnd, handleDragCancel, @@ -375,7 +362,7 @@ export function SwimlaneKanbanContent({ const { isMobile, isTablet } = useResponsiveBreakpoint(); const { activeIndex, setActiveIndex } = useMobileColumnIndex(steps, tasks); const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } = - useSwimlaneKanbanDnd({ tasks, workflowId, useTouchSensors: isMobile || isTablet, onMoveError }); + useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }); if (steps.length === 0) return null; diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index ba35e2417f..2fb0c66a91 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { IconPlayerStop, IconFileTextSpark, @@ -15,15 +15,20 @@ import { IconFileText, IconFileDiff, IconFilePlus, + IconMessagePlus, + IconSubtask, } from "@tabler/icons-react"; import { useRegisterCommands } from "@/hooks/use-register-commands"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { usePanelActions } from "@/hooks/use-panel-actions"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; +import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { createFile } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { NewSessionDialog } from "@/components/task/new-session-dialog"; +import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog"; import type { CommandItem } from "@/lib/commands/types"; type SessionCommandsProps = { @@ -52,7 +57,7 @@ function buildSessionCommands( items.push({ id: "session-cancel", label: "Cancel Turn", - group: "Session", + group: "Agent", icon: , keywords: ["cancel", "stop", "turn"], action: cancelTurn, @@ -61,7 +66,7 @@ function buildSessionCommands( items.push({ id: "session-plan-mode", label: "Toggle Plan Mode", - group: "Session", + group: "Agent", icon: , keywords: ["plan", "mode", "toggle"], action: () => panels.addPlan(), @@ -161,6 +166,32 @@ function buildWorkspaceCommands(sessionId: string): CommandItem[] { ]; } +function buildTaskCommands( + activeTaskId: string | null, + openNewAgent: () => void, + openSubtask: () => void, +): CommandItem[] { + if (!activeTaskId) return []; + return [ + { + id: "agent-new", + label: "New Agent", + group: "Agent", + icon: , + keywords: ["new", "agent", "session", "start"], + action: openNewAgent, + }, + { + id: "subtask-create", + label: "Create Subtask", + group: "Tasks", + icon: , + keywords: ["subtask", "create", "new", "child"], + action: openSubtask, + }, + ]; +} + function buildPanelCommands( panels: PanelActions, isPassthrough: boolean | undefined, @@ -215,6 +246,18 @@ export function SessionCommands({ const { openCommitDialog, openPRDialog } = useVcsDialogs(); const gitWithFeedback = useGitWithFeedback(); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const activeTaskTitle = useAppStore((s) => { + const id = s.tasks.activeTaskId; + if (!id) return ""; + return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? ""; + }); + + const [showNewAgentDialog, setShowNewAgentDialog] = useState(false); + const [showSubtaskDialog, setShowSubtaskDialog] = useState(false); + const openNewAgent = useCallback(() => setShowNewAgentDialog(true), []); + const openSubtask = useCallback(() => setShowSubtaskDialog(true), []); + const cancelTurn = useCallback(async () => { if (!sessionId) return; const client = getWebSocketClient(); @@ -246,10 +289,12 @@ export function SessionCommands({ : []), ...(hasWorktree ? buildWorkspaceCommands(sessionId) : []), ...buildPanelCommands(panels, isPassthrough), + ...buildTaskCommands(activeTaskId, openNewAgent, openSubtask), ]; return items.map((cmd) => ({ ...cmd, priority: 0 })); }, [ sessionId, + activeTaskId, git, panels, cancelTurn, @@ -260,9 +305,29 @@ export function SessionCommands({ openCommitDialog, openPRDialog, runGitWithFeedback, + openNewAgent, + openSubtask, ]); useRegisterCommands(commands); - return null; + return ( + <> + {activeTaskId && ( + + )} + {activeTaskId && ( + + )} + + ); } diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 7e7d9e4796..a24da75334 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -7,7 +7,7 @@ import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import { useContextFilesStore } from "@/lib/state/context-files-store"; -import { linkToSession } from "@/lib/links"; +import { linkToTask } from "@/lib/links"; import { INTENT_PLAN } from "@/lib/state/layout-manager"; import { createTask } from "@/lib/api"; import type { FileAttachment } from "@/components/task/chat/file-attachment"; @@ -97,7 +97,7 @@ export function activatePlanMode({ setActiveDocument(sessionId, { type: "plan", taskId }); setPlanMode(sessionId, true); useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); - router.push(linkToSession(sessionId, INTENT_PLAN)); + router.push(linkToTask(taskId, INTENT_PLAN)); } export type BuildCreatePayloadArgs = { @@ -112,6 +112,7 @@ export type BuildCreatePayloadArgs = { withAgent: boolean; planMode?: boolean; attachments?: MessageAttachment[]; + parentId?: string; }; export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams { @@ -129,6 +130,7 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask executor_profile_id: args.executorProfileId || undefined, plan_mode: args.planMode || undefined, attachments: args.attachments, + parent_id: args.parentId || undefined, }; } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 7aad1e58b3..96f7b9f542 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -129,7 +129,7 @@ export const AgentSelector = memo(function AgentSelector({ searchPlaceholder="Search agents..." emptyMessage="No agent found." disabled={disabled} - dropdownLabel="Agent profile" + dropdownLabel="Agent Profile" className={`min-w-[380px]${disabled ? "" : ` ${CURSOR_POINTER_CLASS}`}`} triggerClassName={triggerClassName} testId="agent-profile-selector" diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index f24284d89d..a630f7c298 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -7,7 +7,7 @@ import { useAppStore } from "@/components/state-provider"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { useToast } from "@/components/toast-provider"; -import { linkToSession } from "@/lib/links"; +import { linkToTask } from "@/lib/links"; import type { SubmitHandlersDeps } from "@/components/task-create-dialog-types"; import { @@ -45,6 +45,7 @@ export function useTaskSubmitHandlers({ onCreateSession, onOpenChange, taskId, + parentTaskId, descriptionInputRef, setIsCreatingSession, setIsCreatingTask, @@ -124,13 +125,10 @@ export function useTaskSubmitHandlers({ prompt: trimmedDescription, attachments: toMessageAttachments(attachments), }); - const response = await launchSession(request); + await launchSession(request); - const newSessionId = response?.session_id; onOpenChange(false); - if (newSessionId) { - router.push(linkToSession(newSessionId)); - } + router.push(linkToTask(taskId)); } catch (error) { toast({ title: "Failed to create session", @@ -256,6 +254,7 @@ export function useTaskSubmitHandlers({ withAgent: true, planMode, attachments, + parentId: parentTaskId, }), ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; @@ -270,8 +269,8 @@ export function useTaskSubmitHandlers({ setPlanMode, router, }); - } else if (isPassthroughProfile && newSessionId) { - router.push(linkToSession(newSessionId)); + } else if (isPassthroughProfile) { + router.push(linkToTask(taskResponse.id)); } }, [ @@ -281,6 +280,7 @@ export function useTaskSubmitHandlers({ executorId, executorProfileId, isPassthroughProfile, + parentTaskId, onSuccess, onOpenChange, clearDraft, @@ -305,6 +305,7 @@ export function useTaskSubmitHandlers({ executorProfileId, withAgent: false, planMode: true, + parentId: parentTaskId, }), ); const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null; @@ -327,6 +328,7 @@ export function useTaskSubmitHandlers({ agentProfileId, executorId, executorProfileId, + parentTaskId, onSuccess, onOpenChange, clearDraft, diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 91f3978712..1081aa4291 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -183,6 +183,7 @@ export type SubmitHandlersDeps = { onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void; onOpenChange: (open: boolean) => void; taskId: string | null; + parentTaskId?: string; descriptionInputRef: React.RefObject; setIsCreatingSession: (v: boolean) => void; setIsCreatingTask: (v: boolean) => void; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index b381fc26b4..3a8e9c148e 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -69,6 +69,7 @@ interface TaskCreateDialogProps { onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void; initialValues?: TaskCreateDialogInitialValues; taskId?: string | null; + parentTaskId?: string; } function getRepositoryPlaceholder( @@ -360,7 +361,14 @@ function DialogFormBody({ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props; - const { editingTask, onSuccess, onCreateSession, initialValues, taskId = null } = props; + const { + editingTask, + onSuccess, + onCreateSession, + initialValues, + taskId = null, + parentTaskId, + } = props; const isSessionMode = mode === "session"; const isEditMode = mode === "edit"; const isCreateMode = mode === "create"; @@ -415,6 +423,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) { onCreateSession, onOpenChange, taskId, + parentTaskId, descriptionInputRef: fs.descriptionInputRef, setIsCreatingSession: fs.setIsCreatingSession, setIsCreatingTask: fs.setIsCreatingTask, diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx index 0fbc791780..1937856d6e 100644 --- a/apps/web/components/task-preview-panel.tsx +++ b/apps/web/components/task-preview-panel.tsx @@ -11,7 +11,7 @@ interface TaskPreviewPanelProps { task: Task | null; sessionId?: string | null; onClose: () => void; - onMaximize?: (task: Task, sessionId: string) => void; + onMaximize?: (task: Task) => void; } export function TaskPreviewPanel({ @@ -46,17 +46,20 @@ export function TaskPreviewPanel({ }; return ( -
+
{/* Header */}

{task?.title ?? "Task Chat"}

- {onMaximize && activeSessionId && task && ( + {onMaximize && task && ( + )} +
-
- + {taskId && } ); } @@ -259,8 +280,7 @@ export const ChatInputContainer = forwardRef ); } diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index 8d3174bc8f..d2389d2234 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -155,6 +155,7 @@ function PlanToggleButton({ type="button" variant="ghost" size="sm" + data-testid="plan-mode-toggle-button" className={cn( "h-7 gap-1.5 px-2 hover:bg-muted/40 cursor-pointer", planModeEnabled && planModeAvailable && "bg-violet-500/15 text-violet-400", @@ -442,7 +443,6 @@ function buildCollapsibleItems(props: { taskId={props.taskId} activeSessionId={props.sessionId} taskTitle={props.taskTitle} - taskDescription={props.taskDescription} /> ), }, diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index f850389565..083276fe54 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -7,13 +7,16 @@ import { type IDockviewPanelProps, type IDockviewPanelHeaderProps, type DockviewReadyEvent, - type SerializedDockview, } from "dockview-react"; import { themeKandev } from "@/lib/layout/dockview-theme"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; -import { applyLayoutFixups, getRootSplitview } from "@/lib/state/dockview-layout-builders"; -import { getSessionLayout, setSessionLayout, getSessionMaximizeState } from "@/lib/local-storage"; -import { useAppStore } from "@/components/state-provider"; +import { tryRestoreLayout } from "./dockview-layout-restore"; +import { + setupGroupTracking, + setupLayoutPersistence, + setupPortalCleanup, +} from "./dockview-layout-setup"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useFileEditors } from "@/hooks/use-file-editors"; import { useLspFileOpener } from "@/hooks/use-lsp-file-opener"; import { useEditorKeybinds } from "@/hooks/use-editor-keybinds"; @@ -34,6 +37,12 @@ import { PassthroughTerminal } from "./passthrough-terminal"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { ContextMenuTab } from "./tab-context-menu"; import { ChangesTab } from "./changes-tab"; +import { SessionTab } from "./session-tab"; +import { + setupSessionTabSync, + setupChatPanelSafetyNet, + useAutoSessionTab, +} from "./dockview-session-tabs"; import { TerminalPanel } from "./terminal-panel"; import { BrowserPanel } from "./browser-panel"; import { VscodePanel } from "./vscode-panel"; @@ -42,8 +51,6 @@ import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel"; import { PreviewController } from "./preview/preview-controller"; import { ReviewDialog } from "@/components/review/review-dialog"; import { BottomTerminalPanel } from "./bottom-terminal-panel"; -import { stopVscode } from "@/lib/api/domains/vscode-api"; -import { stopUserShell } from "@/lib/api/domains/user-shell-api"; import { useReviewDialog } from "./use-review-dialog"; import type { Repository, RepositoryScript } from "@/lib/types/http"; @@ -53,9 +60,6 @@ import type { Terminal } from "@/hooks/domains/session/use-terminals"; import { panelPortalManager, setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host"; -// --- STORAGE KEY --- -const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; - // --------------------------------------------------------------------------- // PORTAL SLOT — generic dockview component that adopts a persistent portal // --------------------------------------------------------------------------- @@ -147,6 +151,7 @@ function PermanentTab(props: IDockviewPanelHeaderProps) { const tabComponents: Record> = { permanentTab: PermanentTab, changesTab: ChangesTab, + sessionTab: SessionTab, }; // --------------------------------------------------------------------------- @@ -171,18 +176,36 @@ function SidebarContent({ panelId }: { panelId: string }) { return ; } -function ChatContent({ panelId }: { panelId: string }) { - const sessionId = useAppStore((state) => state.tasks.activeSessionId); - const { openFile } = useFileEditors(); - - const isPassthrough = useAppStore((state) => { - if (!sessionId) return false; - return state.taskSessions.items[sessionId]?.is_passthrough === true; +function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) { + const agentLabel = useAppStore((state) => { + if (!sessionId) return null; + const session = state.taskSessions.items[sessionId]; + if (!session?.agent_profile_id) return null; + const profile = state.agentProfiles.items.find( + (p: { id: string }) => p.id === session.agent_profile_id, + ); + if (!profile) return null; + const parts = profile.label.split(" \u2022 "); + return parts[1] || parts[0] || profile.label; }); - useEffect(() => { - setPanelTitle(panelId, "Agent"); - }, [panelId]); + let label = "Agent"; + if (isSessionTab && agentLabel) { + label = agentLabel; + } + setPanelTitle(panelId, label); + }, [panelId, isSessionTab, agentLabel]); +} + +function ChatContent({ panelId, params }: { panelId: string; params: Record }) { + const paramSessionId = params?.sessionId as string | undefined; + const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionId = paramSessionId ?? storeSessionId; + const { openFile } = useFileEditors(); + const isPassthrough = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId]?.is_passthrough === true : false, + ); + useChatSessionTitle(panelId, sessionId, !!paramSessionId); if (isPassthrough) { return ( @@ -193,8 +216,14 @@ function ChatContent({ panelId }: { panelId: string }) { ); } - - return ; + return ( + + ); } function DiffViewerContent({ @@ -310,7 +339,7 @@ function renderPanel( case "sidebar": return ; case "chat": - return ; + return ; case "diff-viewer": return ; case "file-editor": @@ -342,254 +371,6 @@ function renderPanel( const VALID_COMPONENTS = new Set(Object.keys(components)); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function sanitizeLayout(layout: any): any { - if (!layout?.panels || !layout?.grid?.root) return layout; - - const invalidIds = new Set(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const validPanels: Record = {}; - for (const [id, panel] of Object.entries(layout.panels)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const comp = (panel as any).contentComponent; - if (comp && VALID_COMPONENTS.has(comp)) { - validPanels[id] = panel; - } else { - invalidIds.add(id); - } - } - - if (invalidIds.size === 0) return layout; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function cleanNode(node: any): any { - if (node.type === "leaf") { - const views = (node.data.views as string[]).filter((v) => !invalidIds.has(v)); - if (views.length === 0) return null; - const activeView = views.includes(node.data.activeView) ? node.data.activeView : views[0]; - return { ...node, data: { ...node.data, views, activeView } }; - } - if (node.type === "branch") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const children = (node.data as any[]).map(cleanNode).filter(Boolean); - if (children.length === 0) return null; - return { ...node, data: children }; - } - return node; - } - - const cleanedRoot = cleanNode(layout.grid.root); - if (!cleanedRoot) return null; - - return { - ...layout, - grid: { ...layout.grid, root: cleanedRoot }, - panels: validPanels, - }; -} - -/** Restore maximize store state alongside layout fixups if saved maximize data exists. */ -function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], sessionId: string | null): void { - const savedMax = sessionId ? getSessionMaximizeState(sessionId) : null; - if (savedMax) { - // Restore the actual maximized dockview layout (2-panel: sidebar + maximized content), - // not just the store state. Without this, the store thinks it's maximized but dockview - // still shows the normal 5-panel layout. - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); - } else { - const ids = applyLayoutFixups(api); - useDockviewStore.setState(ids); - } -} - -/** Try restoring maximize-only state (no full session layout saved). */ -function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], sessionId: string): boolean { - const savedMax = getSessionMaximizeState(sessionId); - if (!savedMax) return false; - try { - api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); - api.layout(api.width, api.height); - const ids = applyLayoutFixups(api); - type LM = import("@/lib/state/layout-manager").LayoutState; - useDockviewStore.setState({ - ...ids, - preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, - }); - return true; - } catch { - return false; - } -} - -function tryRestoreLayout( - api: DockviewReadyEvent["api"], - currentSessionId: string | null, -): boolean { - if (currentSessionId) { - try { - const sessionLayout = getSessionLayout(currentSessionId); - if (sessionLayout) { - const sanitized = sanitizeLayout(sessionLayout); - if (!sanitized) return false; - api.fromJSON(sanitized as SerializedDockview); - applyFixupsWithMaximize(api, currentSessionId); - return true; - } - } catch { - // Per-session restore failed, try global - } - // No saved session layout, but maximize state may exist - // (maximize is saved synchronously, layout persistence is debounced and may not - // have fired before refresh). Restore the maximized dockview layout directly. - if (tryRestoreMaximizeOnly(api, currentSessionId)) return true; - } - - if (!currentSessionId) { - try { - const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); - if (saved) { - const layout = sanitizeLayout(JSON.parse(saved)); - if (!layout) return false; - api.fromJSON(layout); - useDockviewStore.setState(applyLayoutFixups(api)); - return true; - } - } catch { - // Global restore failed, build default - } - } - - return false; -} - -function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { - if (useDockviewStore.getState().isRestoringLayout) return; - if (api.hasMaximizedGroup() || useDockviewStore.getState().preMaximizeLayout !== null) return; - const sv = getRootSplitview(api); - if (!sv || sv.length < 2) return; - try { - const sidebarW = sv.getViewSize(0); - if (sidebarW > 50) { - const current = useDockviewStore.getState().pinnedWidths.get("sidebar"); - if (current !== sidebarW) { - useDockviewStore.getState().setPinnedWidth("sidebar", sidebarW); - } - } - if (sv.length >= 3) { - const rightIdx = sv.length - 1; - const rightW = sv.getViewSize(rightIdx); - if (rightW > 50) { - const current = useDockviewStore.getState().pinnedWidths.get("right"); - if (current !== rightW) { - useDockviewStore.getState().setPinnedWidth("right", rightW); - } - } - } - } catch { - /* noop */ - } -} - -function setupChatPanelSafetyNet(api: DockviewReadyEvent["api"]) { - api.onDidRemovePanel((panel) => { - if (panel.id !== "chat") return; - if (useDockviewStore.getState().isRestoringLayout) return; - requestAnimationFrame(() => { - if (api.getPanel("chat")) return; - const sb = api.getPanel("sidebar"); - api.addPanel({ - id: "chat", - component: "chat", - tabComponent: "permanentTab", - title: "Agent", - position: sb ? { direction: "right", referencePanel: "sidebar" } : undefined, - }); - const nc = api.getPanel("chat"); - if (nc) { - useDockviewStore.setState({ centerGroupId: nc.group.id }); - } - }); - }); -} - -function setupLayoutPersistence( - api: DockviewReadyEvent["api"], - saveTimerRef: React.MutableRefObject | null>, - sessionIdRef: React.MutableRefObject, -) { - api.onDidLayoutChange(() => { - if (useDockviewStore.getState().isRestoringLayout) return; - - if (saveTimerRef.current) clearTimeout(saveTimerRef.current); - saveTimerRef.current = setTimeout(() => { - try { - const json = api.toJSON(); - localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json)); - const sid = sessionIdRef.current; - if (sid) { - setSessionLayout(sid, json); - } - } catch { - // Ignore serialization errors - } - }, 300); - }); -} - -/** - * Clean up portal entries for panels that were permanently removed (user - * closed a tab), but NOT during layout restores where fromJSON temporarily - * removes all panels. - */ -function setupGroupTracking(api: DockviewReadyEvent["api"]) { - api.onDidActiveGroupChange((group) => { - useDockviewStore.setState({ activeGroupId: group?.id ?? null }); - }); - useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null }); - api.onDidLayoutChange(() => trackPinnedWidths(api)); - trackPinnedWidths(api); -} - -function setupPortalCleanup(api: DockviewReadyEvent["api"]) { - api.onDidRemovePanel((panel) => { - if (useDockviewStore.getState().isRestoringLayout) return; - const isMax = useDockviewStore.getState().preMaximizeLayout !== null; - const remaining = api.panels.filter((p) => p.id !== panel.id); - const nonSidebar = remaining.filter((p) => p.api.component !== "sidebar"); - // If we're in maximize mode and the last non-sidebar panel was just closed, - // exit maximize to restore the pre-maximize layout (avoids empty view). - // Then remove the closed panel from the restored layout so it doesn't reappear. - if (isMax && nonSidebar.length === 0) { - const removedId = panel.id; - requestAnimationFrame(() => { - useDockviewStore.getState().exitMaximizedLayout(); - // exitMaximizedLayout schedules a rAF to finalize — wait for that, then - // remove the panel that was closed (it was re-created from preMaximizeLayout). - requestAnimationFrame(() => { - const restoredPanel = api.getPanel(removedId); - if (restoredPanel) { - restoredPanel.api.close(); - } - }); - }); - } - const entry = panelPortalManager.get(panel.id); - if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId); - if (entry?.component === "terminal" && entry.sessionId) { - const terminalId = entry.params.terminalId as string | undefined; - if (terminalId) stopUserShell(entry.sessionId, terminalId); - } - panelPortalManager.release(panel.id); - }); -} - // --------------------------------------------------------------------------- // useSessionSwitchCleanup — backup layout switch for external session changes // --------------------------------------------------------------------------- @@ -638,6 +419,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const setApi = useDockviewStore((s) => s.setApi); const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout); const saveTimerRef = useRef | null>(null); + const appStore = useAppStoreApi(); const effectiveSessionId = useAppStore((state) => state.tasks.activeSessionId) ?? sessionId ?? null; @@ -675,7 +457,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ const currentSessionId = sessionIdRef.current; // If a layout intent was passed via URL, skip saved layout restoration - const restored = !initialLayout && tryRestoreLayout(api, currentSessionId); + const restored = !initialLayout && tryRestoreLayout(api, currentSessionId, VALID_COMPONENTS); if (!restored) { buildDefaultLayout(api, initialLayout ?? undefined); } @@ -683,16 +465,23 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ useDockviewStore.setState({ currentLayoutSessionId: currentSessionId }); setupGroupTracking(api); - setupChatPanelSafetyNet(api); + setupSessionTabSync(api, appStore); + setupChatPanelSafetyNet(api, appStore); setupLayoutPersistence(api, saveTimerRef, sessionIdRef); setupPortalCleanup(api); }, - [setApi, buildDefaultLayout, initialLayout], + [setApi, buildDefaultLayout, initialLayout, appStore], ); - // Release session-scoped portals + trigger layout switch on session change + // Release session-scoped portals + trigger layout switch on session change. + // IMPORTANT: this must run BEFORE useAutoSessionTab so the old layout is + // saved before a new session tab is created — otherwise the new session's + // panel could leak into the old session's persisted layout. useSessionSwitchCleanup(effectiveSessionId); + // Auto-create a session tab when a session becomes active + useAutoSessionTab(effectiveSessionId); + // Clean up on unmount (e.g. navigating away from session page) useEffect(() => { const timerRef = saveTimerRef; diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 3c223d1ed2..873f132e7e 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -1,9 +1,10 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { type IDockviewHeaderActionsProps } from "dockview-react"; import { IconPlus, + IconMessagePlus, IconDeviceDesktop, IconTerminal2, IconFileText, @@ -25,6 +26,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; @@ -34,10 +36,12 @@ import { prPanelLabel } from "@/components/github/pr-utils"; import { startProcess } from "@/lib/api"; import { createUserShell } from "@/lib/api/domains/user-shell-api"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; -import { linkToSession } from "@/lib/links"; +import { replaceTaskUrl } from "@/lib/links"; import type { Task, ProcessInfo } from "@/lib/types/http"; import type { ProcessStatusEntry } from "@/lib/state/slices"; -import { NewTaskButton } from "./task-session-sidebar"; +import { NewSessionDialog } from "./new-session-dialog"; +import { NewTaskDropdown } from "./new-task-dropdown"; +import { SessionReopenMenuItems } from "./session-reopen-menu"; /** Map a ProcessInfo response to a ProcessStatusEntry for the store. */ function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry { @@ -55,103 +59,163 @@ function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry { }; } -export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { - const { group, containerApi } = props; +function useLeftHeaderState( + groupId: string, + containerApi: IDockviewHeaderActionsProps["containerApi"], +) { const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId); - + const centerGroupId = useDockviewStore((s) => s.centerGroupId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const taskId = useAppStore((state) => state.tasks.activeTaskId); const isPassthrough = useAppStore((state) => { if (!activeSessionId) return false; return state.taskSessions.items[activeSessionId]?.is_passthrough === true; }); - - const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel); - const addVscodePanel = useDockviewStore((s) => s.addVscodePanel); - const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel); - const addPlanPanel = useDockviewStore((s) => s.addPlanPanel); - const addFilesPanel = useDockviewStore((s) => s.addFilesPanel); - const addChangesPanel = useDockviewStore((s) => s.addChangesPanel); - const addPRPanel = useDockviewStore((s) => s.addPRPanel); const pr = useActiveTaskPR(); - const hasChanges = Boolean( containerApi.getPanel("changes") ?? containerApi.getPanel("diff-files"), ); const hasFiles = Boolean(containerApi.getPanel("files") ?? containerApi.getPanel("all-files")); + return { + isSidebarGroup: groupId === sidebarGroupId, + isCenterGroup: groupId === centerGroupId, + activeSessionId, + taskId, + isPassthrough, + pr, + hasChanges, + hasFiles, + }; +} - const isSidebarGroup = group.id === sidebarGroupId; +function AddPanelMenuItems({ + groupId, + state, + onNewSession, + onAddTerminal, +}: { + groupId: string; + state: ReturnType; + onNewSession: () => void; + onAddTerminal: () => void; +}) { + const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel); + const addVscodePanel = useDockviewStore((s) => s.addVscodePanel); + const addPlanPanel = useDockviewStore((s) => s.addPlanPanel); + const addFilesPanel = useDockviewStore((s) => s.addFilesPanel); + const addChangesPanel = useDockviewStore((s) => s.addChangesPanel); + const addPRPanel = useDockviewStore((s) => s.addPRPanel); + + return ( + <> + {state.taskId && ( + <> + + + New Agent + + + + + )} + + + Terminal + + addBrowserPanel(undefined, groupId)} + className="cursor-pointer text-xs" + > + + Browser + + addVscodePanel()} className="cursor-pointer text-xs"> + + VS Code + + {!state.isPassthrough && ( + addPlanPanel(groupId)} className="cursor-pointer text-xs"> + + Plan + + )} + {!state.hasChanges && ( + addChangesPanel(groupId)} + className="cursor-pointer text-xs" + > + + Changes + + )} + {!state.hasFiles && ( + addFilesPanel(groupId)} className="cursor-pointer text-xs"> + + Files + + )} + {state.pr && ( + addPRPanel()} className="cursor-pointer text-xs"> + + {prPanelLabel(state.pr.pr_number)} + + )} + + ); +} + +export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { + const { group, containerApi } = props; + const state = useLeftHeaderState(group.id, containerApi); + const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel); + const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); const handleAddTerminal = useCallback(async () => { - if (!activeSessionId) return; + if (!state.activeSessionId) return; try { - const result = await createUserShell(activeSessionId); + const result = await createUserShell(state.activeSessionId); addTerminalPanel(result.terminalId, group.id); } catch (error) { console.error("Failed to create terminal:", error); } - }, [activeSessionId, addTerminalPanel, group.id]); + }, [state.activeSessionId, addTerminalPanel, group.id]); - if (isSidebarGroup) return null; + if (state.isSidebarGroup) return null; return (
- - - - Terminal - - addBrowserPanel(undefined, group.id)} - className="cursor-pointer text-xs" - > - - Browser - - addVscodePanel()} className="cursor-pointer text-xs"> - - VS Code - - {!isPassthrough && ( - addPlanPanel(group.id)} - className="cursor-pointer text-xs" - > - - Plan - - )} - {!hasChanges && ( - addChangesPanel(group.id)} - className="cursor-pointer text-xs" - > - - Changes - - )} - {!hasFiles && ( - addFilesPanel(group.id)} - className="cursor-pointer text-xs" - > - - Files - - )} - {pr && ( - addPRPanel()} className="cursor-pointer text-xs"> - - {prPanelLabel(pr.pr_number)} - - )} + setShowNewSessionDialog(true)} + onAddTerminal={handleAddTerminal} + /> + {state.taskId && ( + + )}
); } @@ -284,6 +348,12 @@ function SidebarRightActions() { const workspaceId = useAppStore((state) => state.workspaces.activeId); const workflowId = useAppStore((state) => state.workflows.activeId); const kanban = useAppStore((state) => state.kanban); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeTaskTitle = useAppStore((state) => { + const id = state.tasks.activeTaskId; + if (!id) return ""; + return state.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? ""; + }); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const appStore = useAppStoreApi(); @@ -311,19 +381,21 @@ function SidebarRightActions() { if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); performLayoutSwitch(oldSessionId, meta.taskSessionId); - window.history.replaceState({}, "", linkToSession(meta.taskSessionId)); } + replaceTaskUrl(task.id); }, [setActiveTask, setActiveSession, appStore], ); return (
-
); diff --git a/apps/web/components/task/dockview-layout-restore.ts b/apps/web/components/task/dockview-layout-restore.ts new file mode 100644 index 0000000000..3d00c78a2f --- /dev/null +++ b/apps/web/components/task/dockview-layout-restore.ts @@ -0,0 +1,126 @@ +import type { DockviewReadyEvent, SerializedDockview } from "dockview-react"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { applyLayoutFixups } from "@/lib/state/dockview-layout-builders"; +import { getSessionLayout, getSessionMaximizeState } from "@/lib/local-storage"; + +const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function sanitizeLayout(layout: any, validComponents: Set): any { + if (!layout?.panels || !layout?.grid?.root) return null; + + const invalidIds = new Set(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const validPanels: Record = {}; + for (const [id, panel] of Object.entries(layout.panels)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const comp = (panel as any).contentComponent; + if (comp && (validComponents.has(comp) || id.startsWith("session:"))) { + validPanels[id] = panel; + } else { + invalidIds.add(id); + } + } + + if (invalidIds.size === 0) return layout; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function cleanNode(node: any): any { + if (node.type === "leaf") { + const views = (node.data.views as string[]).filter((v) => !invalidIds.has(v)); + if (views.length === 0) return null; + const activeView = views.includes(node.data.activeView) ? node.data.activeView : views[0]; + return { ...node, data: { ...node.data, views, activeView } }; + } + if (node.type === "branch") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children = (node.data as any[]).map(cleanNode).filter(Boolean); + if (children.length === 0) return null; + return { ...node, data: children }; + } + return node; + } + + const cleanedRoot = cleanNode(layout.grid.root); + if (!cleanedRoot) return null; + + return { + ...layout, + grid: { ...layout.grid, root: cleanedRoot }, + panels: validPanels, + }; +} + +function applyFixupsWithMaximize(api: DockviewReadyEvent["api"], sessionId: string | null): void { + const savedMax = sessionId ? getSessionMaximizeState(sessionId) : null; + if (savedMax) { + api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); + api.layout(api.width, api.height); + const ids = applyLayoutFixups(api); + type LM = import("@/lib/state/layout-manager").LayoutState; + useDockviewStore.setState({ + ...ids, + preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, + }); + } else { + const ids = applyLayoutFixups(api); + useDockviewStore.setState(ids); + } +} + +function tryRestoreMaximizeOnly(api: DockviewReadyEvent["api"], sessionId: string): boolean { + const savedMax = getSessionMaximizeState(sessionId); + if (!savedMax) return false; + try { + api.fromJSON(savedMax.maximizedDockviewJson as SerializedDockview); + api.layout(api.width, api.height); + const ids = applyLayoutFixups(api); + type LM = import("@/lib/state/layout-manager").LayoutState; + useDockviewStore.setState({ + ...ids, + preMaximizeLayout: savedMax.preMaximizeLayout as unknown as LM, + }); + return true; + } catch { + return false; + } +} + +export function tryRestoreLayout( + api: DockviewReadyEvent["api"], + currentSessionId: string | null, + validComponents: Set, +): boolean { + if (currentSessionId) { + try { + const sessionLayout = getSessionLayout(currentSessionId); + if (sessionLayout) { + const sanitized = sanitizeLayout(sessionLayout, validComponents); + if (!sanitized) return false; + api.fromJSON(sanitized as SerializedDockview); + applyFixupsWithMaximize(api, currentSessionId); + return true; + } + } catch { + // Per-session restore failed, try global + } + if (tryRestoreMaximizeOnly(api, currentSessionId)) return true; + } + + if (!currentSessionId) { + try { + const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); + if (saved) { + const layout = sanitizeLayout(JSON.parse(saved), validComponents); + if (!layout) return false; + api.fromJSON(layout); + useDockviewStore.setState(applyLayoutFixups(api)); + return true; + } + } catch { + // Global restore failed, build default + } + } + + return false; +} diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts new file mode 100644 index 0000000000..91900e215f --- /dev/null +++ b/apps/web/components/task/dockview-layout-setup.ts @@ -0,0 +1,107 @@ +import type { DockviewReadyEvent } from "dockview-react"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { getRootSplitview } from "@/lib/state/dockview-layout-builders"; +import { setSessionLayout } from "@/lib/local-storage"; +import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; +import { stopVscode } from "@/lib/api/domains/vscode-api"; +import { stopUserShell } from "@/lib/api/domains/user-shell-api"; + +const LAYOUT_STORAGE_KEY = "dockview-layout-v1"; + +function trackPinnedWidths(api: DockviewReadyEvent["api"]): void { + if (useDockviewStore.getState().isRestoringLayout) return; + if (api.hasMaximizedGroup() || useDockviewStore.getState().preMaximizeLayout !== null) return; + const sv = getRootSplitview(api); + if (!sv || sv.length < 2) return; + try { + const sidebarW = sv.getViewSize(0); + if (sidebarW > 50) { + const current = useDockviewStore.getState().pinnedWidths.get("sidebar"); + if (current !== sidebarW) { + useDockviewStore.getState().setPinnedWidth("sidebar", sidebarW); + } + } + if (sv.length >= 3) { + const rightIdx = sv.length - 1; + const rightW = sv.getViewSize(rightIdx); + if (rightW > 50) { + const current = useDockviewStore.getState().pinnedWidths.get("right"); + if (current !== rightW) { + useDockviewStore.getState().setPinnedWidth("right", rightW); + } + } + } + } catch { + /* noop */ + } +} + +export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void { + const d1 = api.onDidActiveGroupChange((group) => { + useDockviewStore.setState({ activeGroupId: group?.id ?? null }); + }); + useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null }); + const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api)); + trackPinnedWidths(api); + return () => { + d1.dispose(); + d2.dispose(); + }; +} + +export function setupLayoutPersistence( + api: DockviewReadyEvent["api"], + saveTimerRef: React.MutableRefObject | null>, + sessionIdRef: React.MutableRefObject, +): void { + api.onDidLayoutChange(() => { + if (useDockviewStore.getState().isRestoringLayout) return; + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + try { + const json = api.toJSON(); + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json)); + const sid = sessionIdRef.current; + if (sid) { + setSessionLayout(sid, json); + } + } catch { + // Ignore serialization errors + } + }, 300); + }); +} + +export function setupPortalCleanup(api: DockviewReadyEvent["api"]): void { + api.onDidRemovePanel((panel) => { + if (useDockviewStore.getState().isRestoringLayout) return; + const isMax = useDockviewStore.getState().preMaximizeLayout !== null; + const remaining = api.panels.filter((p) => p.id !== panel.id); + const nonSidebar = remaining.filter((p) => p.api.component !== "sidebar"); + // If we're in maximize mode and the last non-sidebar panel was just closed, + // exit maximize to restore the pre-maximize layout (avoids empty view). + // Then remove the closed panel from the restored layout so it doesn't reappear. + if (isMax && nonSidebar.length === 0) { + const removedId = panel.id; + requestAnimationFrame(() => { + useDockviewStore.getState().exitMaximizedLayout(); + // exitMaximizedLayout schedules a rAF to finalize — wait for that, then + // remove the panel that was closed (it was re-created from preMaximizeLayout). + requestAnimationFrame(() => { + const restoredPanel = api.getPanel(removedId); + if (restoredPanel) { + restoredPanel.api.close(); + } + }); + }); + } + const entry = panelPortalManager.get(panel.id); + if (entry?.component === "vscode" && entry.sessionId) stopVscode(entry.sessionId); + if (entry?.component === "terminal" && entry.sessionId) { + const terminalId = entry.params.terminalId as string | undefined; + if (terminalId) stopUserShell(entry.sessionId, terminalId); + } + panelPortalManager.release(panel.id); + }); +} diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts new file mode 100644 index 0000000000..e688385c75 --- /dev/null +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef } from "react"; +import type { DockviewReadyEvent, AddPanelOptions } from "dockview-react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; + +/** + * Sync `activeSessionId` in the store when the user clicks a session tab. + * This ensures global panels (changes, files, plan) switch context. + */ +export function setupSessionTabSync(api: DockviewReadyEvent["api"], appStore: StoreApi) { + return api.onDidActivePanelChange((panel) => { + if (!panel) return; + // Ignore panel activations during layout operations (e.g. drag-to-split, + // layout restore) to avoid cascading layout switches. + if (useDockviewStore.getState().isRestoringLayout) return; + // Parse sessionId from panel ID (format: "session:{sessionId}") + if (!panel.id.startsWith("session:")) return; + const sid = panel.id.slice("session:".length); + if (sid && sid !== appStore.getState().tasks.activeSessionId) { + const taskId = appStore.getState().tasks.activeTaskId; + if (taskId) { + // Skip the next layout switch for this session — clicking a tab + // just switches context, the layout stays intact. + useDockviewStore.setState({ _skipLayoutSwitchForSession: sid }); + appStore.getState().setActiveSession(taskId, sid); + } + } + }); +} + +/** + * Re-create a chat or session panel if the last one is removed. + * Prevents the user from ending up with no chat panel at all. + * + * Uses a delayed check to avoid racing with dockview drag-to-split + * operations, which temporarily remove and re-add panels. + */ +export function setupChatPanelSafetyNet( + api: DockviewReadyEvent["api"], + appStore: StoreApi, +) { + return api.onDidRemovePanel((panel) => { + if (useDockviewStore.getState().isRestoringLayout) return; + const isChatPanel = panel.id === "chat" || panel.id.startsWith("session:"); + if (!isChatPanel) return; + // Double rAF gives dockview time to finish internal operations like + // drag-to-split moves (remove from old group → add to new group). + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (useDockviewStore.getState().isRestoringLayout) return; + const hasChatPanel = api.panels.some((p) => p.id === "chat" || p.id.startsWith("session:")); + if (hasChatPanel) return; + const activeSessionId = appStore.getState().tasks.activeSessionId; + const sb = api.getPanel("sidebar"); + const position = sb + ? { direction: "right" as const, referencePanel: "sidebar" } + : undefined; + // Only recreate a panel if there's still an active session. + // If all sessions were deleted, leave the layout empty — the user + // can create a new session via the "+" menu. + if (!activeSessionId) return; + api.addPanel({ + id: `session:${activeSessionId}`, + component: "chat", + tabComponent: "sessionTab", + title: "Agent", + params: { sessionId: activeSessionId }, + position, + }); + const nc = api.getPanel(`session:${activeSessionId}`); + if (nc) useDockviewStore.setState({ centerGroupId: nc.group.id }); + }); + }); + }); +} + +/** + * Auto-create a session tab when a session becomes active. + * Replaces the generic "chat" panel with a per-session tab on first use. + */ +export function useAutoSessionTab(effectiveSessionId: string | null) { + const sessionTabCreatedRef = useRef>(new Set()); + useEffect(() => { + if (!effectiveSessionId) return; + const api = useDockviewStore.getState().api; + if (!api) return; + if (api.getPanel(`session:${effectiveSessionId}`)) { + sessionTabCreatedRef.current.add(effectiveSessionId); + return; + } + // In maximized state the session panel is intentionally absent from the layout; + // it will be restored when the user exits maximize (via preMaximizeLayout). + // Adding it here would destroy the saved maximize layout. + if (useDockviewStore.getState().preMaximizeLayout !== null) { + sessionTabCreatedRef.current.add(effectiveSessionId); + return; + } + // Always remove the generic "chat" panel — it's replaced by per-session tabs + const chatPanel = api.getPanel("chat"); + if (chatPanel) { + api.removePanel(chatPanel); + } + // Resolve position: prefer centerGroupId if the group still exists, + // fall back to placing right of sidebar, or omit position entirely. + const { centerGroupId } = useDockviewStore.getState(); + const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId); + let position: AddPanelOptions["position"]; + if (centerGroupExists) { + position = { referenceGroup: centerGroupId }; + } else { + const sb = api.getPanel("sidebar"); + if (sb) { + position = { direction: "right" as const, referencePanel: "sidebar" }; + } + } + api.addPanel({ + id: `session:${effectiveSessionId}`, + component: "chat", + tabComponent: "sessionTab", + title: "Agent", + params: { sessionId: effectiveSessionId }, + position, + }); + const panel = api.getPanel(`session:${effectiveSessionId}`); + if (panel) { + panel.api.setActive(); + useDockviewStore.setState({ centerGroupId: panel.group.id }); + } + sessionTabCreatedRef.current.add(effectiveSessionId); + }, [effectiveSessionId]); +} diff --git a/apps/web/components/task/file-browser-hooks.ts b/apps/web/components/task/file-browser-hooks.ts index 621be54570..6ca5300780 100644 --- a/apps/web/components/task/file-browser-hooks.ts +++ b/apps/web/components/task/file-browser-hooks.ts @@ -212,7 +212,6 @@ type TreeLoaderContext = { function useTreeLoader(ctx: TreeLoaderContext) { const { - sessionId, clearRetryTimer, retryAttemptRef, retryTimerRef, @@ -221,6 +220,10 @@ function useTreeLoader(ctx: TreeLoaderContext) { setLoadState, setLoadError, } = ctx; + // Use ref for sessionId so loadTree stays stable across session switches. + // This prevents the tree-clearing effect from re-firing when only sessionId changes. + const sessionIdRef = useRef(ctx.sessionId); + sessionIdRef.current = ctx.sessionId; const loadInFlightRef = useRef(false); const loadTree = useCallback( async (options?: { resetRetry?: boolean }) => { @@ -236,7 +239,7 @@ function useTreeLoader(ctx: TreeLoaderContext) { try { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client not available"); - const response = await requestFileTree(client, sessionId, "", 1); + const response = await requestFileTree(client, sessionIdRef.current, "", 1); setTree(response.root ?? null); setLoadState("loaded"); retryAttemptRef.current = 0; @@ -265,7 +268,6 @@ function useTreeLoader(ctx: TreeLoaderContext) { clearRetryTimer, retryAttemptRef, retryTimerRef, - sessionId, setIsLoadingTree, setLoadError, setLoadState, @@ -275,8 +277,15 @@ function useTreeLoader(ctx: TreeLoaderContext) { return loadTree; } -/** Hook for tree loading with retry logic, file-change subscription, and expanded state. */ -export function useFileBrowserTree(sessionId: string) { +/** + * Hook for tree loading with retry logic, file-change subscription, and expanded state. + * @param sessionId - Session ID for API calls (agentctl routing). + * @param resetKey - Optional stable key (e.g. environmentId) that controls when the tree + * does a full reset. When sessions share the same environment, this prevents the tree + * from flashing on tab switch. + */ +export function useFileBrowserTree(sessionId: string, resetKey?: string) { + const effectiveResetKey = resetKey ?? sessionId; const [tree, setTree] = useState(null); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [isLoadingTree, setIsLoadingTree] = useState(true); @@ -285,6 +294,8 @@ export function useFileBrowserTree(sessionId: string) { const hasInitializedExpandedRef = useRef(null); const retryAttemptRef = useRef(0); const retryTimerRef = useRef(null); + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; const agentctlStatus = useSessionAgentctl(sessionId); const { visibleLoadingPaths, showLoading, hideLoading } = useLoadingTimers(); @@ -306,6 +317,7 @@ export function useFileBrowserTree(sessionId: string) { setLoadError, }); + // Full reset: clear tree + loading state. Only fires when the environment changes. useEffect(() => { setTree(null); setIsLoadingTree(true); @@ -314,14 +326,14 @@ export function useFileBrowserTree(sessionId: string) { retryAttemptRef.current = 0; clearRetryTimer(); hasInitializedExpandedRef.current = null; - const savedPaths = getFilesPanelExpandedPaths(sessionId); + const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey); setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set()); - if (savedPaths.length > 0) hasInitializedExpandedRef.current = sessionId; + if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey; void loadTree({ resetRetry: true }); return () => { clearRetryTimer(); }; - }, [clearRetryTimer, loadTree, sessionId]); + }, [clearRetryTimer, loadTree, effectiveResetKey]); useEffect(() => { if (!agentctlStatus.isReady || loadState === "loaded") return; @@ -329,17 +341,17 @@ export function useFileBrowserTree(sessionId: string) { }, [agentctlStatus.isReady, loadState, loadTree]); useEffect(() => { - if (!tree || isLoadingTree || hasInitializedExpandedRef.current === sessionId) return; - hasInitializedExpandedRef.current = sessionId; + if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return; + hasInitializedExpandedRef.current = effectiveResetKey; setExpandedPaths(new Set()); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tree?.children?.length, isLoadingTree, sessionId]); + }, [tree?.children?.length, isLoadingTree, effectiveResetKey]); useEffect(() => { - if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === sessionId) { - setFilesPanelExpandedPaths(sessionId, Array.from(expandedPaths)); + if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === effectiveResetKey) { + setFilesPanelExpandedPaths(effectiveResetKey, Array.from(expandedPaths)); } - }, [expandedPaths, sessionId]); + }, [expandedPaths, effectiveResetKey]); useEffect(() => { const client = getWebSocketClient(); @@ -347,9 +359,16 @@ export function useFileBrowserTree(sessionId: string) { return client.on("session.workspace.file.changes", (msg) => { const changes = msg.payload?.changes; if (!changes || changes.length === 0) return; - applyFileChanges({ client, sessionId, expandedPaths, changes, setTree, setLoadState }); + applyFileChanges({ + client, + sessionId: sessionIdRef.current, + expandedPaths, + changes, + setTree, + setLoadState, + }); }); - }, [sessionId, expandedPaths]); + }, [expandedPaths]); const collapseAll = useCallback(() => { setExpandedPaths(new Set()); diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 49aaf5328a..3f706a7873 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -83,6 +83,7 @@ function FileBrowserHeader({ type FileBrowserProps = { sessionId: string; + environmentId?: string | null; onOpenFile: (file: OpenFileTab) => void; onCreateFile?: (path: string) => Promise; onDeleteFile?: (path: string) => Promise; @@ -161,6 +162,7 @@ function useFileBrowserHandlers( export function FileBrowser({ sessionId, + environmentId, onOpenFile, onCreateFile, onDeleteFile, @@ -175,7 +177,7 @@ export function FileBrowser({ const scrollAreaRef = useRef(null); const search = useFileBrowserSearch(sessionId); - const treeState = useFileBrowserTree(sessionId); + const treeState = useFileBrowserTree(sessionId, environmentId ?? undefined); const isTreeLoaded = !treeState.isLoadingTree && treeState.tree !== null; useScrollPersistence(sessionId, isTreeLoaded, scrollAreaRef, treeState.tree); diff --git a/apps/web/components/task/files-panel.tsx b/apps/web/components/task/files-panel.tsx index b1fceedc4e..b41781a956 100644 --- a/apps/web/components/task/files-panel.tsx +++ b/apps/web/components/task/files-panel.tsx @@ -6,6 +6,7 @@ import { useAppStore } from "@/components/state-provider"; import { useFileOperations } from "@/hooks/use-file-operations"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { FileBrowser } from "@/components/task/file-browser"; +import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import type { OpenFileTab } from "@/lib/types/backend"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; @@ -14,7 +15,17 @@ type FilesPanelProps = { }; const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { - const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + // Use environment-stable sessionId so the file browser doesn't re-fetch + // when switching between sessions in the same environment. + const activeSessionId = useEnvironmentSessionId(); + const environmentId = useAppStore((state) => { + if (!activeSessionId) return null; + return ( + state.environmentIdBySessionId[activeSessionId] ?? + state.taskSessions.items[activeSessionId]?.task_environment_id ?? + null + ); + }); const activeFilePath = useDockviewStore((s) => s.activeFilePath); const isArchived = useIsTaskArchived(); const { createFile, deleteFile, renameFile } = useFileOperations(activeSessionId ?? null); @@ -47,7 +58,9 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { {activeSessionId ? ( state.tasks.activeSessionId); const sessionsById = useAppStore((state) => state.taskSessions.items); const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); - const gitStatusBySessionId = useAppStore((state) => state.gitStatus.bySessionId); + const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); + const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); const { tasks } = useTasks(workflowId); const steps = useAppStore((state) => state.kanban.steps); const workspaces = useAppStore((state) => state.workspaces.items); @@ -98,7 +99,12 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) { repositories.map((repo: Repository) => [repo.id, repo.local_path]), ); return tasks.map((task: KanbanState["tasks"][number]) => { - const sessionInfo = getSessionInfoForTask(task.id, sessionsByTaskId, gitStatusBySessionId); + const sessionInfo = getSessionInfoForTask( + task.id, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + ); return { id: task.id, title: task.title, @@ -116,7 +122,14 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) { primarySessionId: task.primarySessionId ?? null, }; }); - }, [repositoriesByWorkspace, tasks, workspaceId, sessionsByTaskId, gitStatusBySessionId]); + }, [ + repositoriesByWorkspace, + tasks, + workspaceId, + sessionsByTaskId, + gitStatusByEnvId, + envIdBySessionId, + ]); const dialogSteps = useMemo( () => @@ -187,10 +200,10 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { const mostRecentSession = sortByUpdatedAtDesc(sessions)[0]; if (mostRecentSession) { setActiveSession(mostRecentTask.id, mostRecentSession.id); - replaceSessionUrl(mostRecentSession.id); } else { setActiveTask(mostRecentTask.id); } + replaceTaskUrl(mostRecentTask.id); } onOpenChange(false); } catch (error) { @@ -245,8 +258,8 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { setActiveTask(task.id); if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); - replaceSessionUrl(meta.taskSessionId); } + replaceTaskUrl(task.id); onOpenChange(false); }, [store, setActiveTask, setActiveSession, onOpenChange], @@ -269,8 +282,8 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea const task = kanbanTasks.find((t) => t.id === taskId); if (task?.primarySessionId) { setActiveSession(taskId, task.primarySessionId); - replaceSessionUrl(task.primarySessionId); loadTaskSessionsForTask(taskId); + replaceTaskUrl(taskId); onOpenChange(false); return; } @@ -278,7 +291,7 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea const sessionId = sessions[0]?.id ?? null; if (sessionId) { setActiveSession(taskId, sessionId); - replaceSessionUrl(sessionId); + replaceTaskUrl(taskId); onOpenChange(false); return; } @@ -288,14 +301,12 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea const resp = await launchSession(request); if (resp.session_id) { setActiveSession(taskId, resp.session_id); - replaceSessionUrl(resp.session_id); - onOpenChange(false); - return; } } catch { // Fall through to default behavior } setActiveTask(taskId); + replaceTaskUrl(taskId); onOpenChange(false); }); }, diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx new file mode 100644 index 0000000000..a632ce4632 --- /dev/null +++ b/apps/web/components/task/new-session-dialog.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { Button } from "@kandev/ui/button"; +import { Textarea } from "@kandev/ui/textarea"; +import { useAppStore } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { launchSession } from "@/lib/services/session-launch-service"; +import { buildStartRequest } from "@/lib/services/session-launch-helpers"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { addSessionPanel } from "@/lib/state/dockview-panel-actions"; + +import { AgentSelector } from "@/components/task-create-dialog-selectors"; +import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; +import { toMessageAttachments } from "@/components/task-create-dialog-helpers"; +import { useSummarizeSession } from "@/hooks/use-summarize-session"; +import { useTaskSessions } from "@/hooks/use-task-sessions"; +import type { AgentProfileOption } from "@/lib/state/slices"; +import { IconLoader2 } from "@tabler/icons-react"; +import { + EnvironmentBadges, + ContextSelect, + useDialogAttachments, + AttachButton, + toContextItems, +} from "./session-dialog-shared"; +import { ContextZone } from "./chat/context-items/context-zone"; + +type NewSessionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + taskId: string; + groupId?: string; +}; + +function useNewSessionDialogState(taskId: string) { + const taskTitle = useAppStore((state) => { + const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId); + return task?.title ?? "Task"; + }); + const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const currentSession = useAppStore((state) => { + return activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null; + }); + const worktreeBranch = useAppStore((state) => { + if (!activeSessionId) return null; + const wtIds = state.sessionWorktreesBySessionId.itemsBySessionId[activeSessionId]; + if (wtIds?.length) { + const wt = state.worktrees.items[wtIds[0]]; + if (wt?.branch) return wt.branch; + } + return currentSession?.worktree_branch ?? null; + }); + const initialPrompt = useAppStore((state) => { + if (!activeSessionId) return null; + const msgs = state.messages.bySession[activeSessionId]; + if (!msgs?.length) return null; + const first = msgs.find((m: { author_type?: string }) => m.author_type === "user"); + return first ? ((first as { content?: string }).content ?? null) : null; + }); + const executorLabel = useAppStore((state) => { + if (!currentSession?.executor_id) return null; + const executor = state.executors.items.find( + (e: { id: string }) => e.id === currentSession.executor_id, + ); + return executor?.name ?? null; + }); + + return { taskTitle, agentProfiles, currentSession, worktreeBranch, initialPrompt, executorLabel }; +} + +function activateNewSession( + sessionId: string, + taskId: string, + tabLabel: string, + groupId: string | undefined, + setActiveSession: (taskId: string, sessionId: string) => void, +) { + useDockviewStore.setState({ _skipLayoutSwitchForSession: sessionId }); + setActiveSession(taskId, sessionId); + const { api, centerGroupId } = useDockviewStore.getState(); + if (api) addSessionPanel(api, groupId ?? centerGroupId, sessionId, tabLabel); +} + +function useSessionOptions(taskId: string) { + const { sessions, loadSessions } = useTaskSessions(taskId); + const agentProfiles = useAppStore((s) => s.agentProfiles.items); + useEffect(() => { + loadSessions(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return useMemo(() => { + const sorted = [...sessions].sort( + (a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + return sorted.map((s, idx) => { + const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id); + const parts = profile?.label.split(" \u2022 "); + const name = parts?.[1] || parts?.[0] || "Agent"; + return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name }; + }); + }, [sessions, agentProfiles]); +} + +// eslint-disable-next-line max-lines-per-function +function NewSessionForm({ + taskId, + defaultProfileId, + executorId, + executorLabel, + worktreeBranch, + initialPrompt, + agentProfiles, + groupId, + onClose, +}: { + taskId: string; + defaultProfileId: string; + executorId: string; + executorLabel: string | null; + worktreeBranch: string | null; + initialPrompt: string | null; + agentProfiles: AgentProfileOption[]; + groupId?: string; + onClose: () => void; +}) { + const { toast } = useToast(); + const setActiveSession = useAppStore((state) => state.setActiveSession); + const { summarize, isSummarizing } = useSummarizeSession(); + const [isCreating, setIsCreating] = useState(false); + const [contextValue, setContextValue] = useState("blank"); + const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId); + const [hasPrompt, setHasPrompt] = useState(false); + const promptRef = useRef(null); + const { + attachments, + isDragging, + fileInputRef, + handleRemoveAttachment, + handlePaste, + handleDragOver, + handleDragLeave, + handleDrop, + handleAttachClick, + handleFileInputChange, + } = useDialogAttachments(isCreating || isSummarizing); + const contextItems = useMemo( + () => toContextItems(attachments, handleRemoveAttachment), + [attachments, handleRemoveAttachment], + ); + const profileOptions = useAgentProfileOptions(agentProfiles); + const sessionOptions = useSessionOptions(taskId); + + const handleContextChange = useCallback( + async (value: string) => { + if (!value) return; + setContextValue(value); + if (value === "copy_prompt" && initialPrompt && promptRef.current) { + promptRef.current.value = initialPrompt; + setHasPrompt(true); + } else if (value === "blank" && promptRef.current) { + promptRef.current.value = ""; + setHasPrompt(false); + } else if (value.startsWith("summarize:")) { + const sessionId = value.slice("summarize:".length); + const result = await summarize(sessionId); + if (result && promptRef.current) { + promptRef.current.value = result; + setHasPrompt(true); + } else { + setContextValue("blank"); + toast({ + title: "Summarize failed", + description: + "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.", + variant: "error", + }); + } + } + }, + [initialPrompt, summarize, toast], + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const typed = promptRef.current?.value?.trim() ?? ""; + const prompt = + contextValue === "copy_prompt" && !typed && initialPrompt ? initialPrompt : typed; + if (!prompt) return; + setIsCreating(true); + try { + const { request } = buildStartRequest(taskId, selectedProfileId || defaultProfileId, { + executorId, + prompt, + attachments: toMessageAttachments(attachments), + }); + const response = await launchSession(request); + if (!response.session_id) { + throw new Error("Session created but no session ID returned"); + } + const profile = agentProfiles.find( + (p: AgentProfileOption) => p.id === (selectedProfileId || defaultProfileId), + ); + activateNewSession( + response.session_id, + taskId, + profile?.label ?? "Agent", + groupId, + setActiveSession, + ); + onClose(); + } catch (error) { + toast({ + title: "Failed to create session", + description: error instanceof Error ? error.message : "Unknown error", + variant: "error", + }); + } finally { + setIsCreating(false); + } + }, + [ + taskId, + selectedProfileId, + defaultProfileId, + executorId, + contextValue, + initialPrompt, + agentProfiles, + groupId, + onClose, + toast, + setActiveSession, + attachments, + ], + ); + + const showSessions = sessionOptions; + + return ( +
+ + {profileOptions.length > 1 && ( +
+ + +
+ )} + +
+
+ +