feat: improve task creation (#220)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -199,7 +199,6 @@ export function TasksPageClient({
|
||||
setCreateDialogOpen(false)
|
||||
fetchTasks()
|
||||
}}
|
||||
navigateOnSessionCreate={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user