feat: improve task creation (#220)

This commit is contained in:
Carlos Florêncio
2026-02-13 17:26:38 +00:00
committed by GitHub
parent e2ba35b3bd
commit ea751d3ecc
32 changed files with 1177 additions and 699 deletions
+11
View File
@@ -115,6 +115,11 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
}, nil
}
// SetExecutionDescription updates the task description in an existing execution's metadata.
func (a *lifecycleAdapter) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error {
return a.mgr.SetExecutionDescription(ctx, agentExecutionID, description)
}
// StartAgentProcess starts the agent subprocess for an instance.
// The command is built internally based on the instance's agent profile.
func (a *lifecycleAdapter) StartAgentProcess(ctx context.Context, agentInstanceID string) error {
@@ -232,6 +237,12 @@ func (w *orchestratorWrapper) ResumeTaskSession(ctx context.Context, taskID, tas
return err
}
// StartCreatedSession forwards to the orchestrator service, discarding the TaskExecution result.
func (w *orchestratorWrapper) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string) error {
_, err := w.svc.StartCreatedSession(ctx, taskID, sessionID, agentProfileID, prompt)
return err
}
// messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface
type messageCreatorAdapter struct {
svc *taskservice.Service
@@ -867,6 +867,20 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
return execution, nil
}
// SetExecutionDescription updates the task description stored in an execution's metadata.
// This is used when starting an agent on a workspace that was launched without a prompt.
func (m *Manager) SetExecutionDescription(_ context.Context, executionID string, description string) error {
execution, exists := m.executionStore.Get(executionID)
if !exists {
return fmt.Errorf("execution %q not found", executionID)
}
if execution.Metadata == nil {
execution.Metadata = make(map[string]interface{})
}
execution.Metadata["task_description"] = description
return nil
}
// StartAgentProcess configures and starts the agent subprocess for an execution.
// This must be called after Launch() to actually start the agent (e.g., auggie, codex).
// The command is built internally based on the execution's agent profile.
@@ -151,6 +151,11 @@ func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *exec
}, nil
}
// SetExecutionDescription updates the task description for an execution
func (s *SimulatedAgentManagerClient) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error {
return nil
}
// StartAgentProcess simulates starting the agent subprocess
func (s *SimulatedAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error {
s.logger.Info("simulated: starting agent process",
@@ -62,6 +62,10 @@ type AgentManagerClient interface {
// ResolveAgentProfile resolves an agent profile ID to profile information
ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
// SetExecutionDescription updates the task description in an existing execution's metadata.
// Used when starting an agent on a session whose workspace was already launched.
SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error
}
// AgentProfileInfo contains resolved profile information
@@ -150,6 +154,11 @@ func agentSessionStateToV1(state models.TaskSessionState) v1.TaskSessionState {
return v1.TaskSessionState(state)
}
// TaskStateChangeFunc is called when the executor needs to update a task's state.
// When set, it replaces direct repo.UpdateTaskState calls so the caller can
// publish events (e.g. WebSocket notifications) alongside the DB update.
type TaskStateChangeFunc func(ctx context.Context, taskID string, state v1.TaskState) error
// Executor manages agent execution for tasks
type Executor struct {
agentManager AgentManagerClient
@@ -161,6 +170,10 @@ type Executor struct {
retryLimit int
retryDelay time.Duration
// Callback for task state changes that need event publishing.
// Set by the orchestrator to route through the task service layer.
onTaskStateChange TaskStateChangeFunc
// Per-session locks to prevent concurrent resume/launch operations on the same session.
// This prevents race conditions when the backend restarts and multiple resume requests
// arrive simultaneously (e.g., from frontend auto-resume).
@@ -188,6 +201,57 @@ func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, lo
}
}
// SetOnTaskStateChange sets a callback for task state changes.
// This allows the orchestrator to route state changes through the task service layer
// which publishes WebSocket events. Without this, async goroutines would only update
// the database, leaving the frontend out of sync.
func (e *Executor) SetOnTaskStateChange(fn TaskStateChangeFunc) {
e.onTaskStateChange = fn
}
// startAgentProcessAsync starts the agent subprocess in a background goroutine.
// On success it transitions the task to IN_PROGRESS; on failure it marks both the
// session and task as FAILED.
func (e *Executor) startAgentProcessAsync(taskID, sessionID, agentExecutionID string) {
go func() {
startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := e.agentManager.StartAgentProcess(startCtx, agentExecutionID); err != nil {
e.logger.Error("failed to start agent process",
zap.String("task_id", taskID),
zap.String("agent_execution_id", agentExecutionID),
zap.Error(err))
if updateErr := e.repo.UpdateTaskSessionState(context.Background(), sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil {
e.logger.Warn("failed to mark session as failed after start error",
zap.String("session_id", sessionID),
zap.Error(updateErr))
}
if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateFailed); updateErr != nil {
e.logger.Warn("failed to mark task as failed after start error",
zap.String("task_id", taskID),
zap.Error(updateErr))
}
return
}
if updateErr := e.updateTaskState(context.Background(), taskID, v1.TaskStateInProgress); updateErr != nil {
e.logger.Warn("failed to update task state to IN_PROGRESS after agent start",
zap.String("task_id", taskID),
zap.Error(updateErr))
}
}()
}
// updateTaskState updates a task's state, using the callback if set for event publishing,
// or falling back to the raw repository.
func (e *Executor) updateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
if e.onTaskStateChange != nil {
return e.onTaskStateChange(ctx, taskID, state)
}
return e.repo.UpdateTaskState(ctx, taskID, state)
}
// shouldUseWorktree returns true if the given executor type should use Git worktrees.
func shouldUseWorktree(executorType string) bool {
return models.ExecutorType(executorType) == models.ExecutorTypeWorktree
@@ -260,7 +324,7 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
}
// Launch the agent for the prepared session
return e.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, prompt, workflowStepID)
return e.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, prompt, workflowStepID, true)
}
// PrepareSession creates a session entry in the database without launching the agent.
@@ -357,9 +421,13 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
return sessionID, nil
}
// LaunchPreparedSession launches the agent for a pre-created session.
// LaunchPreparedSession launches the workspace (and optionally the agent) for a pre-created session.
// The session must have been created using PrepareSession.
func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, sessionID string, agentProfileID string, executorID string, prompt string, workflowStepID string) (*TaskExecution, error) {
// When startAgent is false, only the workspace infrastructure (agentctl) is launched; the agent
// subprocess is not started and the session state remains CREATED.
// When startAgent is true and the workspace was already launched (AgentExecutionID set), only the
// agent subprocess is started.
func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, sessionID string, agentProfileID string, executorID string, prompt string, workflowStepID string, startAgent bool) (*TaskExecution, error) {
// Fetch the session to get its configuration
session, err := e.repo.GetTaskSession(ctx, sessionID)
if err != nil {
@@ -373,6 +441,12 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
return nil, fmt.Errorf("session does not belong to task")
}
// Fast path: workspace already launched (e.g., from PrepareSession with workspace).
// Only start the agent subprocess if requested; otherwise return early.
if session.AgentExecutionID != "" {
return e.startAgentOnExistingWorkspace(ctx, task, session, prompt, startAgent)
}
metadata := cloneMetadata(task.Metadata)
var repositoryPath string
var repositoryID string
@@ -469,7 +543,7 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
zap.String("session_id", sessionID),
zap.Error(updateErr))
}
if updateErr := e.repo.UpdateTaskState(ctx, task.ID, v1.TaskStateFailed); updateErr != nil {
if updateErr := e.updateTaskState(ctx, task.ID, v1.TaskStateFailed); updateErr != nil {
e.logger.Warn("failed to mark task as failed after launch error",
zap.String("task_id", task.ID),
zap.Error(updateErr))
@@ -480,7 +554,9 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
now := time.Now().UTC()
session.AgentExecutionID = resp.AgentExecutionID
session.ContainerID = resp.ContainerID
session.State = models.TaskSessionStateStarting
if startAgent {
session.State = models.TaskSessionStateStarting
}
session.ErrorMessage = ""
session.UpdatedAt = now
@@ -535,47 +611,25 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
}
}
sessionState := v1.TaskSessionStateCreated
if startAgent {
sessionState = v1.TaskSessionStateStarting
}
execution := &TaskExecution{
TaskID: task.ID,
AgentExecutionID: resp.AgentExecutionID,
AgentProfileID: agentProfileID,
StartedAt: session.StartedAt,
SessionState: v1.TaskSessionStateStarting,
SessionState: sessionState,
LastUpdate: now,
SessionID: sessionID,
WorktreePath: resp.WorktreePath,
WorktreeBranch: resp.WorktreeBranch,
}
// Start the agent process asynchronously
go func() {
startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := e.agentManager.StartAgentProcess(startCtx, resp.AgentExecutionID); err != nil {
e.logger.Error("failed to start agent process",
zap.String("task_id", task.ID),
zap.String("agent_execution_id", resp.AgentExecutionID),
zap.Error(err))
if updateErr := e.repo.UpdateTaskSessionState(context.Background(), sessionID, models.TaskSessionStateFailed, err.Error()); updateErr != nil {
e.logger.Warn("failed to mark session as failed after start error",
zap.String("session_id", sessionID),
zap.Error(updateErr))
}
if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateFailed); updateErr != nil {
e.logger.Warn("failed to mark task as failed after start error",
zap.String("task_id", task.ID),
zap.Error(updateErr))
}
return
}
if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateInProgress); updateErr != nil {
e.logger.Warn("failed to update task state to IN_PROGRESS after agent start",
zap.String("task_id", task.ID),
zap.Error(updateErr))
}
}()
if startAgent {
e.startAgentProcessAsync(task.ID, sessionID, resp.AgentExecutionID)
}
e.logger.Info("agent launched for prepared session",
zap.String("task_id", task.ID),
@@ -585,6 +639,66 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
return execution, nil
}
// startAgentOnExistingWorkspace handles the case where LaunchPreparedSession is called on a session
// whose workspace (agentctl) was already launched. It optionally starts just the agent subprocess.
func (e *Executor) startAgentOnExistingWorkspace(ctx context.Context, task *v1.Task, session *models.TaskSession, prompt string, startAgent bool) (*TaskExecution, error) {
if !startAgent {
// Workspace already launched, nothing else to do
now := time.Now().UTC()
return &TaskExecution{
TaskID: task.ID,
AgentExecutionID: session.AgentExecutionID,
AgentProfileID: session.AgentProfileID,
StartedAt: session.StartedAt,
SessionState: v1.TaskSessionState(session.State),
LastUpdate: now,
SessionID: session.ID,
}, nil
}
// Update the task description in the existing execution so StartAgentProcess picks it up
if prompt != "" {
if err := e.agentManager.SetExecutionDescription(ctx, session.AgentExecutionID, prompt); err != nil {
e.logger.Warn("failed to set execution description for existing workspace",
zap.String("session_id", session.ID),
zap.String("agent_execution_id", session.AgentExecutionID),
zap.Error(err))
// Non-fatal: agent may start without description
}
}
// Transition session to STARTING
now := time.Now().UTC()
session.State = models.TaskSessionStateStarting
session.ErrorMessage = ""
session.UpdatedAt = now
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
e.logger.Error("failed to update session state for agent start",
zap.String("session_id", session.ID),
zap.Error(err))
}
execution := &TaskExecution{
TaskID: task.ID,
AgentExecutionID: session.AgentExecutionID,
AgentProfileID: session.AgentProfileID,
StartedAt: now,
SessionState: v1.TaskSessionStateStarting,
LastUpdate: now,
SessionID: session.ID,
}
// Start the agent process asynchronously
e.startAgentProcessAsync(task.ID, session.ID, session.AgentExecutionID)
e.logger.Info("agent starting on existing workspace",
zap.String("task_id", task.ID),
zap.String("session_id", session.ID),
zap.String("agent_execution_id", session.AgentExecutionID))
return execution, nil
}
// ResumeSession restarts an existing task session using its stored worktree.
// When startAgent is false, only the executor runtime is started (agent process is not launched).
func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSession, startAgent bool) (*TaskExecution, error) {
@@ -858,7 +972,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
zap.String("session_id", session.ID),
zap.Error(updateErr))
}
if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateFailed); updateErr != nil {
if updateErr := e.updateTaskState(context.Background(), task.ID, v1.TaskStateFailed); updateErr != nil {
e.logger.Warn("failed to mark task as failed after start error on resume",
zap.String("task_id", task.ID),
zap.Error(updateErr))
@@ -870,7 +984,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
// If the session is waiting for input, the task should be in REVIEW state.
// This ensures the task state reflects the actual agent status.
if session.State == models.TaskSessionStateWaitingForInput {
if updateErr := e.repo.UpdateTaskState(context.Background(), task.ID, v1.TaskStateReview); updateErr != nil {
if updateErr := e.updateTaskState(context.Background(), task.ID, v1.TaskStateReview); updateErr != nil {
e.logger.Warn("failed to update task state to REVIEW after resume",
zap.String("task_id", task.ID),
zap.Error(updateErr))
@@ -12,9 +12,10 @@ import (
// mockAgentManager implements AgentManagerClient for testing
type mockAgentManager struct {
launchAgentFunc func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error)
startAgentProcessFunc func(ctx context.Context, agentExecutionID string) error
resolveAgentProfileFunc func(ctx context.Context, profileID string) (*AgentProfileInfo, error)
launchAgentFunc func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error)
startAgentProcessFunc func(ctx context.Context, agentExecutionID string) error
resolveAgentProfileFunc func(ctx context.Context, profileID string) (*AgentProfileInfo, error)
setExecutionDescriptionFunc func(ctx context.Context, agentExecutionID string, description string) error
}
func (m *mockAgentManager) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
@@ -28,6 +29,13 @@ func (m *mockAgentManager) LaunchAgent(ctx context.Context, req *LaunchAgentRequ
}, nil
}
func (m *mockAgentManager) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error {
if m.setExecutionDescriptionFunc != nil {
return m.setExecutionDescriptionFunc(ctx, agentExecutionID, description)
}
return nil
}
func (m *mockAgentManager) StartAgentProcess(ctx context.Context, agentExecutionID string) error {
if m.startAgentProcessFunc != nil {
return m.startAgentProcessFunc(ctx, agentExecutionID)
@@ -611,7 +619,7 @@ func TestLaunchPreparedSession_Success(t *testing.T) {
Description: "Test description",
}
execution, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "test prompt", "")
execution, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "test prompt", "", true)
if err != nil {
t.Fatalf("LaunchPreparedSession failed: %v", err)
}
@@ -651,12 +659,146 @@ func TestLaunchPreparedSession_SessionNotBelongsToTask(t *testing.T) {
WorkspaceID: "workspace-123",
}
_, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "test prompt", "")
_, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "test prompt", "", true)
if err == nil {
t.Error("Expected error when session doesn't belong to task")
}
}
func TestLaunchPreparedSession_WorkspaceOnly(t *testing.T) {
repo := newMockRepository()
// Pre-create session (as PrepareSession would)
session := &models.TaskSession{
ID: "session-123",
TaskID: "task-123",
AgentProfileID: "profile-123",
State: models.TaskSessionStateCreated,
StartedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.sessions[session.ID] = session
launchCalled := false
startAgentCalled := false
agentManager := &mockAgentManager{
launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
launchCalled = true
return &LaunchAgentResponse{
AgentExecutionID: "exec-123",
ContainerID: "container-123",
Status: v1.AgentStatusStarting,
}, nil
},
startAgentProcessFunc: func(ctx context.Context, id string) error {
startAgentCalled = true
return nil
},
}
executor := newTestExecutor(t, agentManager, repo)
task := &v1.Task{
ID: "task-123",
WorkspaceID: "workspace-123",
Title: "Test Task",
Description: "Test description",
}
// startAgent=false: should launch workspace but NOT start agent
execution, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "", "", false)
if err != nil {
t.Fatalf("LaunchPreparedSession(startAgent=false) failed: %v", err)
}
if !launchCalled {
t.Error("Expected LaunchAgent to be called (workspace setup)")
}
// Give goroutines a moment to run (there shouldn't be any)
time.Sleep(50 * time.Millisecond)
if startAgentCalled {
t.Error("Expected StartAgentProcess NOT to be called when startAgent=false")
}
if execution.SessionState != v1.TaskSessionStateCreated {
t.Errorf("Expected session state CREATED, got %s", execution.SessionState)
}
// Session in DB should remain CREATED
updatedSession := repo.sessions["session-123"]
if updatedSession.State != models.TaskSessionStateCreated {
t.Errorf("Expected DB session state CREATED, got %s", updatedSession.State)
}
}
func TestLaunchPreparedSession_ExistingWorkspace_StartAgent(t *testing.T) {
repo := newMockRepository()
// Session already has an AgentExecutionID (workspace previously launched)
session := &models.TaskSession{
ID: "session-123",
TaskID: "task-123",
AgentProfileID: "profile-123",
AgentExecutionID: "exec-existing",
State: models.TaskSessionStateCreated,
StartedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.sessions[session.ID] = session
startAgentCalled := false
descriptionSet := ""
agentManager := &mockAgentManager{
startAgentProcessFunc: func(ctx context.Context, id string) error {
startAgentCalled = true
if id != "exec-existing" {
t.Errorf("Expected execution ID exec-existing, got %s", id)
}
return nil
},
}
agentManager.setExecutionDescriptionFunc = func(ctx context.Context, id, desc string) error {
descriptionSet = desc
return nil
}
executor := newTestExecutor(t, agentManager, repo)
task := &v1.Task{
ID: "task-123",
WorkspaceID: "workspace-123",
Title: "Test Task",
}
execution, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", "profile-123", "", "build the feature", "", true)
if err != nil {
t.Fatalf("LaunchPreparedSession(existing workspace) failed: %v", err)
}
// Should use the existing execution ID
if execution.AgentExecutionID != "exec-existing" {
t.Errorf("Expected agent execution ID exec-existing, got %s", execution.AgentExecutionID)
}
if execution.SessionState != v1.TaskSessionStateStarting {
t.Errorf("Expected session state STARTING, got %s", execution.SessionState)
}
// Description should have been set
if descriptionSet != "build the feature" {
t.Errorf("Expected description 'build the feature', got %q", descriptionSet)
}
// Wait for async goroutine
time.Sleep(100 * time.Millisecond)
if !startAgentCalled {
t.Error("Expected StartAgentProcess to be called")
}
}
func TestExecuteWithProfile_UsesPrepareThenLaunch(t *testing.T) {
repo := newMockRepository()
@@ -116,8 +116,8 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required when creating a new session", nil)
}
// If session_id is provided, resume existing session with workflow step config
if req.SessionID != "" {
// If session_id is provided with a workflow step, resume with step config
if req.SessionID != "" && req.WorkflowStepID != "" {
err := h.service.StartSessionForWorkflowStep(ctx, req.TaskID, req.SessionID, req.WorkflowStepID)
if err != nil {
h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err))
@@ -132,6 +132,29 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// If session_id is provided without a workflow step, start a CREATED session with prompt
if req.SessionID != "" {
execution, err := h.service.StartCreatedSession(ctx, req.TaskID, req.SessionID, req.AgentProfileID, req.Prompt)
if err != nil {
h.logger.Error("failed to start created session", zap.String("task_id", req.TaskID), zap.String("session_id", req.SessionID), zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start session: "+err.Error(), nil)
}
resp := dto.StartTaskResponse{
Success: true,
TaskID: execution.TaskID,
AgentExecutionID: execution.AgentExecutionID,
TaskSessionID: execution.SessionID,
State: string(execution.SessionState),
}
if execution.WorktreePath != "" {
resp.WorktreePath = &execution.WorktreePath
}
if execution.WorktreeBranch != "" {
resp.WorktreeBranch = &execution.WorktreeBranch
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// Otherwise, create a new session
execution, err := h.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt, req.WorkflowStepID, false)
if err != nil {
@@ -43,6 +43,10 @@ func (m *mockAgentManager) LaunchAgent(ctx context.Context, req *executor.Launch
}, nil
}
func (m *mockAgentManager) SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error {
return nil
}
func (m *mockAgentManager) StartAgentProcess(ctx context.Context, agentExecutionID string) error {
return nil
}
@@ -199,6 +199,12 @@ func NewService(
}
exec := executor.NewExecutor(agentManager, repo, log, execCfg)
// Wire executor task state changes through the task service layer so
// events are published (e.g. WebSocket notifications to the frontend).
exec.SetOnTaskStateChange(func(ctx context.Context, taskID string, state v1.TaskState) error {
return taskRepo.UpdateTaskState(ctx, taskID, state)
})
// Create the scheduler with queue, executor, and task repository
sched := scheduler.NewScheduler(taskQueue, exec, taskRepo, log, cfg.Scheduler)
@@ -6,9 +6,12 @@ import (
"fmt"
"os"
"strings"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator/dto"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/queue"
@@ -71,6 +74,16 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr
return "", err
}
// Launch workspace infrastructure (agentctl) without starting the agent subprocess.
// This enables file browsing, editing, etc. while the session is in CREATED state.
if _, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, "", workflowStepID, false); err != nil {
s.logger.Warn("failed to launch workspace for prepared session (file browsing may be unavailable)",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
// Non-fatal: session is still usable, workspace will be launched when agent starts
}
s.logger.Info("task session prepared",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
@@ -111,7 +124,7 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi
effectivePrompt, planModeActive := s.applyWorkflowAndPlanMode(ctx, effectivePrompt, task.ID, workflowStepID, planMode)
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, effectivePrompt, workflowStepID)
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, agentProfileID, executorID, effectivePrompt, workflowStepID, true)
if err != nil {
return nil, err
}
@@ -123,6 +136,67 @@ func (s *Service) StartTaskWithSession(ctx context.Context, taskID string, sessi
return execution, nil
}
// StartCreatedSession starts agent execution for a task using a session that is in CREATED state.
// This is used when a session was prepared (via PrepareSession) but the agent was not launched,
// and the user now wants to start the agent with a prompt (e.g., from the plan panel or chat).
func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string) (*executor.TaskExecution, error) {
s.logger.Debug("starting created session",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("agent_profile_id", agentProfileID))
// Load and verify session
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
if session.TaskID != taskID {
return nil, fmt.Errorf("session does not belong to task")
}
if session.State != models.TaskSessionStateCreated {
return nil, fmt.Errorf("session is not in CREATED state (current: %s)", session.State)
}
// Use agent profile from request, fall back to session's stored value
effectiveProfileID := agentProfileID
if effectiveProfileID == "" {
effectiveProfileID = session.AgentProfileID
}
if effectiveProfileID == "" {
return nil, fmt.Errorf("agent_profile_id is required")
}
// Transition task state: CREATED → SCHEDULING → (IN_PROGRESS via executor)
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to update task state to SCHEDULING",
zap.String("task_id", taskID),
zap.Error(err))
}
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
effectivePrompt := prompt
if effectivePrompt == "" {
effectivePrompt = task.Description
}
executorID := session.ExecutorID
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, effectiveProfileID, executorID, effectivePrompt, "", true)
if err != nil {
return nil, err
}
if execution.SessionID != "" {
s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, false)
}
return execution, nil
}
// StartTask manually starts agent execution for a task.
// If workflowStepID is provided and workflowStepGetter is set, the prompt will be built
// using the step's prompt_prefix + base prompt + prompt_suffix, and plan mode will be
@@ -145,6 +219,36 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
zap.Error(err))
}
// Move task to the target workflow step if provided and different from current
if workflowStepID != "" {
dbTask, err := s.repo.GetTask(ctx, taskID)
if err == nil && dbTask.WorkflowStepID != workflowStepID {
dbTask.WorkflowStepID = workflowStepID
dbTask.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, dbTask); err != nil {
s.logger.Warn("failed to move task to workflow step",
zap.String("task_id", taskID),
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
} else if s.eventBus != nil {
_ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent(
events.TaskUpdated,
"orchestrator",
map[string]interface{}{
"task_id": dbTask.ID,
"board_id": dbTask.BoardID,
"workflow_step_id": dbTask.WorkflowStepID,
"title": dbTask.Title,
"description": dbTask.Description,
"state": string(dbTask.State),
"priority": dbTask.Priority,
"position": dbTask.Position,
},
))
}
}
}
// Fetch the task from the repository to get complete task info
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
@@ -26,6 +26,7 @@ import (
type OrchestratorService interface {
PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment) (*orchestrator.PromptResult, error)
ResumeTaskSession(ctx context.Context, taskID, taskSessionID string) error
StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string) error
}
// MessageHandlers handles WebSocket requests for messages
@@ -162,6 +163,9 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
"Session has ended. Please create a new session to continue.", nil)
}
// Handle CREATED sessions: save the message, then start the agent with it as the prompt
isCreatedSession := sessionResp.Session.State == models.TaskSessionStateCreated
// Get the current task state to determine if we need to transition
task, err := h.taskController.GetTask(ctx, dto.GetTaskRequest{ID: req.TaskID})
if err != nil {
@@ -217,8 +221,39 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
model := req.Model
planMode := req.PlanMode
attachments := req.Attachments
startCreated := isCreatedSession
agentProfileID := sessionResp.Session.AgentProfileID
go func() {
promptCtx := context.WithoutCancel(ctx)
// For CREATED sessions, start the agent with this message as the initial prompt
if startCreated {
if err := h.orchestrator.StartCreatedSession(promptCtx, taskID, sessionID, agentProfileID, content); err != nil {
h.logger.Warn("failed to start created session from message",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
errorMsg := "Failed to start agent"
if _, createErr := h.messageController.CreateMessage(promptCtx, dto.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
Content: errorMsg,
AuthorType: "agent",
Type: string(v1.MessageTypeError),
Metadata: map[string]interface{}{
"error": err.Error(),
},
}); createErr != nil {
h.logger.Error("failed to create error message",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(createErr))
}
}
return
}
_, err := h.orchestrator.PromptTask(promptCtx, taskID, sessionID, content, model, planMode, attachments)
if err != nil {
if errors.Is(err, executor.ErrExecutionNotFound) {
@@ -228,6 +228,7 @@ type httpCreateTaskRequest struct {
Position int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
StartAgent bool `json:"start_agent,omitempty"`
PrepareSession bool `json:"prepare_session,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
PlanMode bool `json:"plan_mode,omitempty"`
@@ -249,7 +250,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"})
return
}
if body.StartAgent && body.AgentProfileID == "" {
if (body.StartAgent || body.PrepareSession) && body.AgentProfileID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required to start agent"})
return
}
@@ -289,7 +290,16 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
}
response := createTaskResponse{TaskDTO: resp}
if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil {
if body.PrepareSession && !body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil {
// Create session entry without launching the agent.
// The session stays in CREATED state until the user triggers it.
sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, body.WorkflowStepID)
if err != nil {
h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", resp.ID))
} else {
response.TaskSessionID = sessionID
}
} else if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil {
// Create session entry synchronously so we can return the session ID immediately
sessionID, err := h.orchestrator.PrepareTaskSession(reqCtx, resp.ID, body.AgentProfileID, body.ExecutorID, body.WorkflowStepID)
if err != nil {
+60 -2
View File
@@ -487,12 +487,66 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
return nil, err
}
// Resolve local paths to repository IDs (same logic as CreateTask)
var repoByPath map[string]*models.Repository
for _, repoInput := range req.Repositories {
if repoInput.RepositoryID == "" && repoInput.LocalPath != "" {
repos, err := s.repo.ListRepositories(ctx, task.WorkspaceID)
if err != nil {
s.logger.Error("failed to list repositories", zap.Error(err))
return nil, err
}
repoByPath = make(map[string]*models.Repository, len(repos))
for _, repo := range repos {
if repo.LocalPath == "" {
continue
}
repoByPath[repo.LocalPath] = repo
}
break
}
}
// Create new repositories
for i, repoInput := range req.Repositories {
repositoryID := repoInput.RepositoryID
baseBranch := repoInput.BaseBranch
if repositoryID == "" && repoInput.LocalPath != "" {
repo := repoByPath[repoInput.LocalPath]
if repo == nil {
name := strings.TrimSpace(repoInput.Name)
if name == "" {
name = filepath.Base(repoInput.LocalPath)
}
defaultBranch := repoInput.DefaultBranch
if defaultBranch == "" {
defaultBranch = repoInput.BaseBranch
}
created, err := s.CreateRepository(ctx, &CreateRepositoryRequest{
WorkspaceID: task.WorkspaceID,
Name: name,
SourceType: "local",
LocalPath: repoInput.LocalPath,
DefaultBranch: defaultBranch,
})
if err != nil {
return nil, err
}
repo = created
if repoByPath == nil {
repoByPath = make(map[string]*models.Repository)
}
repoByPath[repoInput.LocalPath] = repo
}
repositoryID = repo.ID
if baseBranch == "" {
baseBranch = repo.DefaultBranch
}
}
taskRepo := &models.TaskRepository{
TaskID: task.ID,
RepositoryID: repoInput.RepositoryID,
BaseBranch: repoInput.BaseBranch,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
Position: i,
Metadata: make(map[string]interface{}),
}
@@ -1651,6 +1705,10 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
}
}
if len(task.Repositories) > 0 {
data["repository_id"] = task.Repositories[0].RepositoryID
}
if task.Metadata != nil {
data["metadata"] = task.Metadata
}
-1
View File
@@ -199,7 +199,6 @@ export function TasksPageClient({
setCreateDialogOpen(false)
fetchTasks()
}}
navigateOnSessionCreate={false}
/>
)}
</div>
+8 -2
View File
@@ -54,11 +54,17 @@ export const Combobox = memo(function Combobox({
showSearch = true,
}: ComboboxProps) {
const [open, setOpen] = useState(false)
// Track the highlighted item. Defaults to the selected value so the current
// selection is highlighted when the popover opens (not the first item).
const [highlighted, setHighlighted] = useState("")
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={(next) => {
setOpen(next)
if (next) setHighlighted(value)
}}>
<PopoverTrigger asChild>
<Button
variant="ghost"
@@ -78,7 +84,7 @@ export const Combobox = memo(function Combobox({
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-[var(--radix-popover-trigger-width)] min-w-[300px] max-w-none p-0", className)} align="start">
<Command>
<Command value={highlighted} onValueChange={setHighlighted}>
{dropdownLabel ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
{dropdownLabel}
+2 -6
View File
@@ -61,7 +61,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
handleWorkspaceChange,
handleBoardChange,
deletingTaskId,
} = useKanbanActions({ kanban, workspaceState, boardsState });
} = useKanbanActions({ workspaceState, boardsState });
// Data fetching and derived state
const {
@@ -215,7 +215,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
onSearchChange={setSearchQuery}
/>
<TaskCreateDialog
key={isDialogOpen ? 'open' : 'closed'}
open={isDialogOpen}
onOpenChange={handleDialogOpenChange}
workspaceId={workspaceState.activeId}
@@ -235,7 +234,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
: null
}
onSuccess={handleDialogSuccess}
navigateOnSessionCreate={false}
initialValues={
editingTask
? {
@@ -246,11 +244,10 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
}
: undefined
}
submitLabel={editingTask ? 'Update' : 'Create'}
mode={editingTask ? 'edit' : 'create'}
/>
{/* Workflow automation session creation dialog */}
<TaskCreateDialog
key={isWorkflowDialogOpen ? 'workflow-open' : 'workflow-closed'}
open={isWorkflowDialogOpen}
onOpenChange={(open) => {
setIsWorkflowDialogOpen(open);
@@ -267,7 +264,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
title: '',
description: workflowAutomation?.taskDescription ?? '',
}}
submitLabel="Start Agent"
/>
{/* Approval warning modal */}
<AlertDialog open={!!moveError} onOpenChange={(open) => !open && setMoveError(null)}>
+2 -2
View File
@@ -138,7 +138,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on
style={style}
className={cn(
'max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0',
task.state === 'IN_PROGRESS' && 'kanban-task-pulse',
(task.state === 'IN_PROGRESS' || task.state === 'SCHEDULING') && 'kanban-task-pulse',
isDragging && 'opacity-50 z-50'
)}
onClick={() => onClick?.(task)}
@@ -151,7 +151,7 @@ export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, on
repoName={repositoryName ?? null}
actions={
<div className="flex items-center gap-2">
{task.state === 'IN_PROGRESS' && statusIcon}
{(task.state === 'IN_PROGRESS' || task.state === 'SCHEDULING') && statusIcon}
{showMaximizeButton && onOpenFullPage && task.hasSession !== false && (
<button
type="button"
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ type AgentStatusProps = {
};
const STATE_CONFIG: Record<TaskSessionState, { label: string; icon: 'spinner' | 'error' | 'warning' | null }> = {
CREATED: { label: 'Agent is being created', icon: 'spinner' },
CREATED: { label: '', icon: null },
STARTING: { label: 'Agent is starting', icon: 'spinner' },
RUNNING: { label: 'Agent is running', icon: 'spinner' },
WAITING_FOR_INPUT: { label: '', icon: null },
@@ -43,7 +43,8 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
const previousHeightRef = useRef(0);
const isInitialLoading = messagesLoading && messages.length === 0;
const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking;
const isCreatedSession = sessionState === 'CREATED';
const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking && !isCreatedSession;
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId);
const itemCount = items.length;
@@ -454,7 +454,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="task"
mode="create"
workspaceId={workspaceId}
boardId={boardId}
defaultColumnId={dialogColumns[0]?.id ?? null}
+1 -1
View File
@@ -64,7 +64,7 @@ export const TaskItem = memo(function TaskItem({
// Effective open state: keep menu open while deleting
const effectiveMenuOpen = menuOpen || isDeleting;
const isInProgress = state === 'IN_PROGRESS' || state === 'SCHEDULING' || state === 'CREATED';
const isInProgress = state === 'IN_PROGRESS' || state === 'SCHEDULING';
const hasDiffStats = diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
// Determine what to show on the right side
@@ -46,7 +46,7 @@ export const NewTaskButton = memo(function NewTaskButton({
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="task"
mode="create"
workspaceId={workspaceId}
boardId={boardId}
defaultColumnId={columns[0]?.id ?? null}
@@ -8,12 +8,11 @@ import type { Task as BackendTask } from '@/lib/types/http';
import type { KanbanState, WorkspaceState, BoardState } from '@/lib/state/slices';
type UseKanbanActionsOptions = {
kanban: KanbanState;
workspaceState: WorkspaceState;
boardsState: BoardState;
};
export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKanbanActionsOptions) {
export function useKanbanActions({ workspaceState, boardsState }: UseKanbanActionsOptions) {
const router = useRouter();
const store = useAppStoreApi();
@@ -31,40 +30,64 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
} = useTaskCRUD();
// Handle task dialog success (create/update)
// Read current kanban state at call time (not from closure) to avoid
// overwriting WebSocket-driven updates that arrived while the dialog was open.
const handleDialogSuccess = useCallback(
(task: BackendTask, mode: 'create' | 'edit') => {
const currentKanban = store.getState().kanban;
if (mode === 'create') {
store.getState().hydrate({
kanban: {
...kanban,
tasks: [
...kanban.tasks,
{
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
const repoId = task.repositories?.[0]?.repository_id ?? undefined;
const existing = currentKanban.tasks.find((t: KanbanState['tasks'][number]) => t.id === task.id);
if (existing) {
// WebSocket may have added the task already but without repositoryId.
// Merge in any missing fields from the API response.
if (repoId && !existing.repositoryId) {
store.getState().hydrate({
kanban: {
...currentKanban,
tasks: currentKanban.tasks.map((t: KanbanState['tasks'][number]) =>
t.id === task.id ? { ...t, repositoryId: repoId } : t
),
},
],
},
});
});
}
} else {
store.getState().hydrate({
kanban: {
...currentKanban,
tasks: [
...currentKanban.tasks,
{
id: task.id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: repoId,
},
],
},
});
}
// Invalidate workspace repos cache so newly created repos show up in the edit dialog
if (task.workspace_id) {
store.getState().invalidateRepositories(task.workspace_id);
}
return;
}
// Only update fields that the edit dialog changes (title, description, repositories).
// State and workflowStepId are managed by the backend via WebSocket events
// (e.g. orchestrator.start moves the task to the auto-start column).
store.getState().hydrate({
kanban: {
...kanban,
tasks: kanban.tasks.map((item: KanbanState['tasks'][number]) =>
...currentKanban,
tasks: currentKanban.tasks.map((item: KanbanState['tasks'][number]) =>
item.id === task.id
? {
...item,
title: task.title,
description: task.description ?? undefined,
workflowStepId: task.workflow_step_id ?? item.workflowStepId,
position: task.position ?? item.position,
state: task.state ?? item.state,
repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId,
}
: item
@@ -72,7 +95,7 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
},
});
},
[kanban, store]
[store]
);
// Handle workspace change with navigation
@@ -13,7 +13,7 @@ export function useSessionState(sessionId: string | null) {
const taskDescription = task?.description ?? null;
const isStarting = session?.state === 'STARTING';
const isWorking = isStarting || session?.state === 'RUNNING';
const isAgentBusy = session?.state === 'CREATED' || session?.state === 'RUNNING';
const isAgentBusy = session?.state === 'RUNNING';
const isFailed = session?.state === 'FAILED' || session?.state === 'CANCELLED';
return {
@@ -15,11 +15,17 @@ export function useSession(sessionId: string | null): UseSessionResult {
sessionId ? state.taskSessions.items[sessionId] ?? null : null
);
const connectionStatus = useAppStore((state) => state.connection.status);
const agentctlReady = useAppStore((state) =>
sessionId ? state.sessionAgentctl.itemsBySessionId[sessionId]?.status === 'ready' : false
);
const isActive = useMemo(() => {
if (!session?.state) return false;
return session.state === 'RUNNING' || session.state === 'WAITING_FOR_INPUT';
}, [session?.state]);
if (session.state === 'RUNNING' || session.state === 'WAITING_FOR_INPUT') return true;
// Workspace infrastructure (agentctl) is ready even though the agent CLI hasn't started
if (session.state === 'CREATED' && agentctlReady) return true;
return false;
}, [session?.state, agentctlReady]);
const isFailed = useMemo(() => {
return session?.state === 'FAILED';
@@ -24,26 +24,22 @@ export function useRepositoryBranches(repositoryId: string | null, enabled = tru
useEffect(() => {
if (!enabled || !repositoryId) return;
if (isLoaded || inFlightRef.current) return;
let cancelled = false;
inFlightRef.current = true;
setRepositoryBranchesLoading(repositoryId, true);
listRepositoryBranches(repositoryId, { cache: 'no-store' })
// Capture the repositoryId at effect start for the closure
const fetchRepoId = repositoryId;
listRepositoryBranches(fetchRepoId, { cache: 'no-store' })
.then((response) => {
if (cancelled) return;
setRepositoryBranches(repositoryId, response.branches);
// Safe to always store: data is keyed by repositoryId in the store
setRepositoryBranches(fetchRepoId, response.branches);
})
.catch(() => {
if (cancelled) return;
setRepositoryBranches(repositoryId, []);
setRepositoryBranches(fetchRepoId, []);
})
.finally(() => {
inFlightRef.current = false;
if (cancelled) return;
setRepositoryBranchesLoading(repositoryId, false);
setRepositoryBranchesLoading(fetchRepoId, false);
});
return () => {
cancelled = true;
};
}, [enabled, isLoaded, repositoryId, setRepositoryBranches, setRepositoryBranchesLoading]);
return { branches, isLoading };
+1
View File
@@ -39,6 +39,7 @@ export async function createTask(
}>;
state?: Task['state'];
start_agent?: boolean;
prepare_session?: boolean;
agent_profile_id?: string;
executor_id?: string;
plan_mode?: boolean;
+58 -1
View File
@@ -12,6 +12,21 @@ export type FileEditorState = {
isBinary?: boolean;
};
/** Direction relative to a reference panel or group. */
export type PanelDirection = 'left' | 'right' | 'above' | 'below';
/** A deferred panel operation applied after the next layout build / restore. */
export type DeferredPanelAction = {
id: string;
component: string;
title: string;
/** Where to place the panel. 'tab' adds as a tab in the reference group. */
placement: 'tab' | PanelDirection;
/** Panel ID or group alias to position relative to. Defaults to 'chat'. */
referencePanel?: string;
params?: Record<string, unknown>;
};
type DockviewStore = {
api: DockviewApi | null;
setApi: (api: DockviewApi | null) => void;
@@ -54,6 +69,10 @@ type DockviewStore = {
isRestoringLayout: boolean;
currentLayoutSessionId: string | null;
switchSessionLayout: (oldSessionId: string | null, newSessionId: string) => void;
// Deferred panel actions — queued before navigation, applied after next layout build / restore
deferredPanelActions: DeferredPanelAction[];
queuePanelAction: (action: DeferredPanelAction) => void;
};
const SIDEBAR_GROUP = 'group-sidebar';
@@ -82,6 +101,32 @@ function focusOrAddPanel(api: DockviewApi, options: AddPanelOptions & { id: stri
}
}
/**
* Drain the deferred panel action queue, applying each action to the dockview API.
* Call this after a layout build or restore so queued panels appear in the new layout.
*/
function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelAction[]): void {
for (const action of actions) {
const ref = action.referencePanel ?? 'chat';
let position: AddPanelOptions['position'];
if (action.placement === 'tab') {
const groupId = api.getPanel(ref)?.group?.id;
if (groupId) {
position = { referenceGroup: groupId };
}
} else {
position = { referencePanel: ref, direction: action.placement };
}
focusOrAddPanel(api, {
id: action.id,
component: action.component,
title: action.title,
position,
...(action.params ? { params: action.params } : {}),
});
}
}
/**
* After fromJSON() restores a layout, apply fixups: re-lock sidebar,
* fix old panel aliases, and return computed group IDs.
@@ -165,6 +210,11 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
isRestoringLayout: false,
currentLayoutSessionId: null,
deferredPanelActions: [],
queuePanelAction: (action) => set((prev) => ({
deferredPanelActions: [...prev.deferredPanelActions, action],
})),
switchSessionLayout: (oldSessionId, newSessionId) => {
const { api, currentLayoutSessionId } = get();
if (!api) return;
@@ -254,7 +304,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
id: 'terminal-default',
component: 'terminal',
title: 'Terminal',
params: { terminalId: 'default' },
params: { terminalId: 'shell-default' },
position: { direction: 'below', referencePanel: 'changes' },
});
@@ -268,6 +318,13 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
rightBottomGroupId: terminalPanel?.group?.id ?? RIGHT_BOTTOM_GROUP,
sidebarGroupId: sidebarGroup.id,
});
// Apply any queued panel actions (e.g., plan panel from plan mode task creation)
const pending = get().deferredPanelActions;
if (pending.length > 0) {
set({ deferredPanelActions: [] });
applyDeferredPanelActions(api, pending);
}
},
resetLayout: () => {
@@ -50,6 +50,7 @@ export type WorkspaceSliceActions = {
setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void;
setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void;
clearRepositoryScripts: (repositoryId: string) => void;
invalidateRepositories: (workspaceId: string) => void;
};
export type WorkspaceSlice = WorkspaceSliceState & WorkspaceSliceActions;
@@ -66,4 +66,8 @@ export const createWorkspaceSlice: StateCreator<
delete draft.repositoryScripts.loadingByRepositoryId[repositoryId];
delete draft.repositoryScripts.loadedByRepositoryId[repositoryId];
}),
invalidateRepositories: (workspaceId) =>
set((draft) => {
draft.repositories.loadedByWorkspaceId[workspaceId] = false;
}),
});
+1
View File
@@ -176,6 +176,7 @@ export type AppState = {
setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void;
setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void;
clearRepositoryScripts: (repositoryId: string) => void;
invalidateRepositories: (workspaceId: string) => void;
setSettingsData: (next: Partial<SettingsDataState>) => void;
setEditors: (editors: EditorsState['items']) => void;
setEditorsLoading: (loading: boolean) => void;
+2 -1
View File
@@ -7,7 +7,7 @@ type KanbanTask = KanbanState['tasks'][number];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask {
return {
const task = {
id: payload.task_id,
workflowStepId: payload.workflow_step_id,
title: payload.title,
@@ -20,6 +20,7 @@ function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask {
reviewStatus: payload.review_status ?? existing?.reviewStatus,
updatedAt: payload.updated_at ?? existing?.updatedAt,
};
return task;
}
function upsertTask(tasks: KanbanTask[], nextTask: KanbanTask): KanbanTask[] {