Clean up dead code and fix ACP session event payload (#73)
This commit is contained in:
@@ -184,42 +184,12 @@ func (a *lifecycleAdapter) RespondToPermissionBySessionID(ctx context.Context, s
|
||||
return a.mgr.RespondToPermissionBySessionID(sessionID, pendingID, optionID, cancelled)
|
||||
}
|
||||
|
||||
// GetRecoveredExecutions returns executions recovered from Docker during startup
|
||||
func (a *lifecycleAdapter) GetRecoveredExecutions() []executor.RecoveredExecutionInfo {
|
||||
recovered := a.mgr.GetRecoveredExecutions()
|
||||
result := make([]executor.RecoveredExecutionInfo, len(recovered))
|
||||
for i, r := range recovered {
|
||||
result[i] = executor.RecoveredExecutionInfo{
|
||||
ExecutionID: r.ExecutionID,
|
||||
TaskID: r.TaskID,
|
||||
SessionID: r.SessionID,
|
||||
ContainerID: r.ContainerID,
|
||||
AgentProfileID: r.AgentProfileID,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsAgentRunningForSession checks if an agent is actually running for a session
|
||||
// This probes the actual agent (Docker container or standalone process)
|
||||
func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
||||
return a.mgr.IsAgentRunningForSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it.
|
||||
func (a *lifecycleAdapter) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
||||
return a.mgr.CleanupStaleExecutionBySessionID(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
func (a *lifecycleAdapter) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
config, err := a.registry.Get(agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config.ToAPIType(), nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
info, err := a.mgr.ResolveAgentProfile(ctx, profileID)
|
||||
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
// Task Service packages
|
||||
taskcontroller "github.com/kandev/kandev/internal/task/controller"
|
||||
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
usercontroller "github.com/kandev/kandev/internal/user/controller"
|
||||
userhandlers "github.com/kandev/kandev/internal/user/handlers"
|
||||
|
||||
@@ -312,69 +311,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up historical logs provider for task subscriptions
|
||||
// Uses messages instead of execution logs - all agent messages are now messages
|
||||
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
|
||||
var session *models.TaskSession
|
||||
session, err = taskRepo.GetActiveTaskSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
session, err = taskRepo.GetTaskSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := taskSvc.ListMessages(ctx, session.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*ws.Message, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
action := ws.ActionSessionMessageAdded
|
||||
payload := map[string]interface{}{
|
||||
"message_id": message.ID,
|
||||
"session_id": message.TaskSessionID,
|
||||
"task_id": message.TaskID,
|
||||
"author_type": string(message.AuthorType),
|
||||
"author_id": message.AuthorID,
|
||||
"content": message.Content,
|
||||
"type": string(message.Type),
|
||||
"requests_input": message.RequestsInput,
|
||||
"created_at": message.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if message.Metadata != nil {
|
||||
payload["metadata"] = message.Metadata
|
||||
}
|
||||
notification, err := ws.NewNotification(action, payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, notification)
|
||||
}
|
||||
|
||||
// Also send current git status if available
|
||||
session, err = taskRepo.GetActiveTaskSessionByTaskID(ctx, taskID)
|
||||
if err == nil && session != nil && session.Metadata != nil {
|
||||
if gitStatus, ok := session.Metadata["git_status"]; ok {
|
||||
// Add task_id to the git status data
|
||||
gitStatusData, ok := gitStatus.(map[string]interface{})
|
||||
if !ok {
|
||||
gitStatusData = map[string]interface{}{"data": gitStatus}
|
||||
}
|
||||
gitStatusData["task_id"] = taskID
|
||||
|
||||
gitStatusNotification, err := ws.NewNotification("session.git.status", gitStatusData)
|
||||
if err == nil {
|
||||
result = append(result, gitStatusNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
})
|
||||
log.Info("Historical logs provider configured for task subscriptions (using messages and git status)")
|
||||
|
||||
// Set up session data provider for session subscriptions
|
||||
// Sends initial git status when a client subscribes to a session
|
||||
gateway.Hub.SetSessionDataProvider(func(ctx context.Context, sessionID string) ([]*ws.Message, error) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
@@ -21,15 +22,13 @@ func provideOrchestrator(
|
||||
lifecycleMgr *lifecycle.Manager,
|
||||
agentRegistry *registry.Registry,
|
||||
) (*orchestrator.Service, error) {
|
||||
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
|
||||
|
||||
var agentManagerClient executor.AgentManagerClient
|
||||
if lifecycleMgr != nil {
|
||||
agentManagerClient = newLifecycleAdapter(lifecycleMgr, agentRegistry, log)
|
||||
} else {
|
||||
agentManagerClient = executor.NewMockAgentManagerClient(log)
|
||||
if lifecycleMgr == nil {
|
||||
return nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
|
||||
}
|
||||
|
||||
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
|
||||
agentManagerClient := newLifecycleAdapter(lifecycleMgr, agentRegistry, log)
|
||||
|
||||
serviceCfg := orchestrator.DefaultServiceConfig()
|
||||
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, log)
|
||||
taskSvc.SetExecutionStopper(orchestratorSvc)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
|
||||
@@ -28,6 +28,7 @@ type AgentctlEventPayload struct {
|
||||
// ACPSessionCreatedPayload is the payload when an ACP session is created.
|
||||
type ACPSessionCreatedPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentInstanceID string `json:"agent_instance_id"`
|
||||
ACPSessionID string `json:"acp_session_id"`
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ func (p *EventPublisher) PublishACPSessionCreated(execution *AgentExecution, ses
|
||||
|
||||
payload := ACPSessionCreatedPayload{
|
||||
TaskID: execution.TaskID,
|
||||
SessionID: execution.SessionID,
|
||||
AgentInstanceID: execution.ID,
|
||||
ACPSessionID: sessionID,
|
||||
}
|
||||
|
||||
@@ -129,16 +129,6 @@ func (s *ExecutionStore) UpdateStatus(executionID string, status v1.AgentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProgress updates the progress of an agent execution.
|
||||
func (s *ExecutionStore) UpdateProgress(executionID string, progress int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if execution, exists := s.executions[executionID]; exists {
|
||||
execution.Progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateError updates the error message of an agent execution and sets its status to failed.
|
||||
func (s *ExecutionStore) UpdateError(executionID string, errorMsg string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
"github.com/kandev/kandev/internal/agent/runtime"
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
agentctl "github.com/kandev/kandev/internal/agentctl/client"
|
||||
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
||||
@@ -130,91 +130,6 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) {
|
||||
m.workspaceInfoProvider = provider
|
||||
}
|
||||
|
||||
// EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session.
|
||||
// This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]).
|
||||
// If an execution already exists for the session, it returns it. Otherwise, it creates a new execution
|
||||
// using the session's workspace configuration from the database.
|
||||
func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) (*AgentExecution, error) {
|
||||
// Check if execution already exists
|
||||
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// Need to create a new execution - get workspace info for the specific session
|
||||
if m.workspaceInfoProvider == nil {
|
||||
return nil, fmt.Errorf("workspace info provider not configured")
|
||||
}
|
||||
|
||||
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, taskID, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get workspace info for session %s: %w", sessionID, err)
|
||||
}
|
||||
|
||||
if info.WorkspacePath == "" {
|
||||
return nil, fmt.Errorf("no workspace path available for session %s", sessionID)
|
||||
}
|
||||
|
||||
m.logger.Info("creating execution for task session",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("workspace_path", info.WorkspacePath),
|
||||
zap.String("acp_session_id", info.ACPSessionID))
|
||||
|
||||
return m.createExecution(ctx, taskID, info)
|
||||
}
|
||||
|
||||
// createExecution creates an agentctl execution.
|
||||
// The agent subprocess is NOT started - call ConfigureAgent + Start explicitly.
|
||||
func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) {
|
||||
if m.runtime == nil {
|
||||
return nil, fmt.Errorf("no runtime configured")
|
||||
}
|
||||
|
||||
if info.AgentID == "" {
|
||||
return nil, fmt.Errorf("agent ID is required in WorkspaceInfo")
|
||||
}
|
||||
|
||||
executionID := uuid.New().String()
|
||||
|
||||
agentConfig, err := m.registry.Get(info.AgentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent type %q not found in registry: %w", info.AgentID, err)
|
||||
}
|
||||
|
||||
req := &RuntimeCreateRequest{
|
||||
InstanceID: executionID,
|
||||
TaskID: taskID,
|
||||
SessionID: info.SessionID,
|
||||
AgentProfileID: info.AgentProfileID,
|
||||
WorkspacePath: info.WorkspacePath,
|
||||
Protocol: string(agentConfig.Protocol),
|
||||
}
|
||||
|
||||
runtimeInstance, err := m.runtime.CreateInstance(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create execution: %w", err)
|
||||
}
|
||||
|
||||
execution := runtimeInstance.ToAgentExecution(req)
|
||||
|
||||
// Set the ACP session ID for session resumption
|
||||
if info.ACPSessionID != "" {
|
||||
execution.ACPSessionID = info.ACPSessionID
|
||||
}
|
||||
|
||||
m.executionStore.Add(execution)
|
||||
|
||||
go m.waitForAgentctlReady(execution)
|
||||
|
||||
m.logger.Info("execution created",
|
||||
zap.String("execution_id", executionID),
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("workspace_path", info.WorkspacePath),
|
||||
zap.String("runtime", string(m.runtime.Name())))
|
||||
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// Start starts the lifecycle manager background tasks
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
runtimeName := "none"
|
||||
@@ -868,8 +783,6 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
|
||||
// Handle different event types based on the Type field
|
||||
switch event.Type {
|
||||
case "message_chunk":
|
||||
m.updateExecutionProgress(execution.ID, 50)
|
||||
|
||||
// Accumulate message content and stream on newline boundaries for real-time feedback
|
||||
if event.Text != "" {
|
||||
execution.messageMu.Lock()
|
||||
@@ -920,17 +833,10 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.String("tool_call_id", event.ToolCallID),
|
||||
zap.String("tool_name", event.ToolName))
|
||||
m.updateExecutionProgress(execution.ID, 60)
|
||||
// Tool call message creation is handled by orchestrator via AgentStreamEvent
|
||||
|
||||
case "tool_update":
|
||||
// Check if tool call completed - orchestrator will create/update the message
|
||||
switch event.ToolStatus {
|
||||
case "complete", "completed":
|
||||
m.updateExecutionProgress(execution.ID, 80)
|
||||
case "error", "failed":
|
||||
// Error status is passed through the stream event
|
||||
}
|
||||
// Tool update handled by orchestrator via AgentStreamEvent
|
||||
|
||||
case "plan":
|
||||
m.logger.Debug("agent plan update",
|
||||
@@ -1127,11 +1033,6 @@ func (m *Manager) publishStreamingMessageFinal(execution *AgentExecution, messag
|
||||
m.eventPublisher.PublishAgentStreamEventPayload(payload)
|
||||
}
|
||||
|
||||
// updateExecutionProgress updates an execution's progress
|
||||
func (m *Manager) updateExecutionProgress(executionID string, progress int) {
|
||||
m.executionStore.UpdateProgress(executionID, progress)
|
||||
}
|
||||
|
||||
// updateExecutionError updates an execution with an error
|
||||
func (m *Manager) updateExecutionError(executionID, errorMsg string) {
|
||||
m.executionStore.UpdateError(executionID, errorMsg)
|
||||
@@ -1279,11 +1180,6 @@ func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bo
|
||||
return m.executionStore.GetBySessionID(sessionID)
|
||||
}
|
||||
|
||||
// GetExecutionByContainerID returns the agent execution for a container
|
||||
func (m *Manager) GetExecutionByContainerID(containerID string) (*AgentExecution, bool) {
|
||||
return m.executionStore.GetByContainerID(containerID)
|
||||
}
|
||||
|
||||
// ListExecutions returns all active executions
|
||||
func (m *Manager) ListExecutions() []*AgentExecution {
|
||||
return m.executionStore.List()
|
||||
@@ -1329,21 +1225,6 @@ func (m *Manager) UpdateStatus(executionID string, status v1.AgentStatus) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProgress updates the progress of an execution
|
||||
func (m *Manager) UpdateProgress(executionID string, progress int) error {
|
||||
if !m.executionStore.WithLock(executionID, func(execution *AgentExecution) {
|
||||
execution.Progress = progress
|
||||
}) {
|
||||
return fmt.Errorf("execution %q not found", executionID)
|
||||
}
|
||||
|
||||
m.logger.Debug("updated execution progress",
|
||||
zap.String("execution_id", executionID),
|
||||
zap.Int("progress", progress))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkReady marks an execution as ready for follow-up prompts
|
||||
func (m *Manager) MarkReady(executionID string) error {
|
||||
execution, exists := m.executionStore.Get(executionID)
|
||||
|
||||
@@ -229,36 +229,6 @@ func TestManager_GetExecutionBySessionID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetExecutionByContainerID(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
|
||||
execution := &AgentExecution{
|
||||
ID: "test-execution-id",
|
||||
TaskID: "test-task-id",
|
||||
AgentProfileID: "test-agent",
|
||||
ContainerID: "container-123",
|
||||
Status: v1.AgentStatusRunning,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
|
||||
mgr.executionStore.Add(execution)
|
||||
|
||||
// Test GetExecutionByContainerID
|
||||
got, found := mgr.GetExecutionByContainerID("container-123")
|
||||
if !found {
|
||||
t.Fatal("expected to find execution")
|
||||
}
|
||||
if got.ContainerID != execution.ContainerID {
|
||||
t.Errorf("expected ContainerID %q, got %q", execution.ContainerID, got.ContainerID)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
_, found = mgr.GetExecutionByContainerID("non-existent")
|
||||
if found {
|
||||
t.Error("expected not to find execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_ListExecutions(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
|
||||
@@ -307,36 +277,6 @@ func TestManager_UpdateStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_UpdateProgress(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
|
||||
execution := &AgentExecution{
|
||||
ID: "test-execution-id",
|
||||
TaskID: "test-task-id",
|
||||
Status: v1.AgentStatusRunning,
|
||||
Progress: 0,
|
||||
}
|
||||
|
||||
mgr.executionStore.Add(execution)
|
||||
|
||||
// Test UpdateProgress
|
||||
err := mgr.UpdateProgress("test-execution-id", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, _ := mgr.GetExecution("test-execution-id")
|
||||
if got.Progress != 50 {
|
||||
t.Errorf("expected progress 50, got %d", got.Progress)
|
||||
}
|
||||
|
||||
// Test not found
|
||||
err = mgr.UpdateProgress("non-existent", 50)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_MarkCompleted_Success(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
|
||||
@@ -436,9 +376,6 @@ func TestManager_RemoveExecution(t *testing.T) {
|
||||
if _, found := mgr.GetExecutionBySessionID("test-session-id"); found {
|
||||
t.Error("execution should be removed from bySession map")
|
||||
}
|
||||
if _, found := mgr.GetExecutionByContainerID("container-123"); found {
|
||||
t.Error("execution should be removed from byContainer map")
|
||||
}
|
||||
|
||||
// Remove non-existent should not panic
|
||||
mgr.RemoveExecution("non-existent")
|
||||
|
||||
@@ -162,15 +162,12 @@ func (c *Client) handleSubscribe(msg *ws.Message) {
|
||||
|
||||
c.hub.SubscribeToTask(c, req.TaskID)
|
||||
|
||||
// Send success response first
|
||||
// Send success response
|
||||
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
||||
"success": true,
|
||||
"task_id": req.TaskID,
|
||||
})
|
||||
c.sendMessage(resp)
|
||||
|
||||
// Send historical logs if available (now includes pending permission request messages)
|
||||
c.sendHistoricalLogs(req.TaskID)
|
||||
}
|
||||
|
||||
type UserSubscribeRequest struct {
|
||||
@@ -250,31 +247,6 @@ func (c *Client) handleUserUnsubscribe(msg *ws.Message) {
|
||||
c.sendMessage(resp)
|
||||
}
|
||||
|
||||
// sendHistoricalLogs sends historical execution logs to the client
|
||||
func (c *Client) sendHistoricalLogs(taskID string) {
|
||||
ctx := context.Background()
|
||||
logs, err := c.hub.GetHistoricalLogs(ctx, taskID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to get historical logs",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(logs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Debug("Sending historical logs",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Int("count", len(logs)))
|
||||
|
||||
// Send each historical log as a notification
|
||||
for _, log := range logs {
|
||||
c.sendMessage(log)
|
||||
}
|
||||
}
|
||||
|
||||
// sendSessionData sends initial session data (e.g., git status) to the client
|
||||
func (c *Client) sendSessionData(sessionID string) {
|
||||
ctx := context.Background()
|
||||
@@ -414,32 +386,10 @@ func (c *Client) WritePump() {
|
||||
return
|
||||
}
|
||||
|
||||
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(message); err != nil {
|
||||
// Send each message in its own WebSocket frame
|
||||
// (previously batched with newlines, but clients expect one JSON object per frame)
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
c.logger.Debug("failed to write websocket message", zap.Error(err))
|
||||
_ = w.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Batch additional queued messages
|
||||
n := len(c.send)
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := w.Write([]byte{'\n'}); err != nil {
|
||||
c.logger.Debug("failed to write websocket delimiter", zap.Error(err))
|
||||
_ = w.Close()
|
||||
return
|
||||
}
|
||||
if _, err := w.Write(<-c.send); err != nil {
|
||||
c.logger.Debug("failed to write queued websocket message", zap.Error(err))
|
||||
_ = w.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// HistoricalLogsProvider is a function that retrieves historical logs for a task
|
||||
type HistoricalLogsProvider func(ctx context.Context, taskID string) ([]*ws.Message, error)
|
||||
|
||||
// SessionDataProvider is a function that retrieves initial data for a session subscription (e.g., git status)
|
||||
type SessionDataProvider func(ctx context.Context, sessionID string) ([]*ws.Message, error)
|
||||
|
||||
@@ -39,9 +36,6 @@ type Hub struct {
|
||||
// Message dispatcher
|
||||
dispatcher *ws.Dispatcher
|
||||
|
||||
// Optional provider for historical logs on subscription
|
||||
historicalLogsProvider HistoricalLogsProvider
|
||||
|
||||
// Optional provider for session data on subscription (e.g., git status)
|
||||
sessionDataProvider SessionDataProvider
|
||||
|
||||
@@ -380,19 +374,6 @@ func (h *Hub) GetDispatcher() *ws.Dispatcher {
|
||||
return h.dispatcher
|
||||
}
|
||||
|
||||
// SetHistoricalLogsProvider sets the provider for historical logs on subscription
|
||||
func (h *Hub) SetHistoricalLogsProvider(provider HistoricalLogsProvider) {
|
||||
h.historicalLogsProvider = provider
|
||||
}
|
||||
|
||||
// GetHistoricalLogs retrieves historical logs for a task if a provider is set
|
||||
func (h *Hub) GetHistoricalLogs(ctx context.Context, taskID string) ([]*ws.Message, error) {
|
||||
if h.historicalLogsProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return h.historicalLogsProvider(ctx, taskID)
|
||||
}
|
||||
|
||||
// SetSessionDataProvider sets the provider for session data on subscription
|
||||
func (h *Hub) SetSessionDataProvider(provider SessionDataProvider) {
|
||||
h.sessionDataProvider = provider
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
|
||||
@@ -22,8 +22,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
@@ -294,50 +293,6 @@ func (s *SimulatedAgentManagerClient) StopAgent(ctx context.Context, agentExecut
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentStatus returns the status of a simulated agent
|
||||
func (s *SimulatedAgentManagerClient) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) {
|
||||
s.mu.Lock()
|
||||
execution, exists := s.instances[agentExecutionID]
|
||||
s.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("agent execution %q not found", agentExecutionID)
|
||||
}
|
||||
|
||||
execution.statusMu.Lock()
|
||||
status := execution.status
|
||||
execution.statusMu.Unlock()
|
||||
|
||||
return &v1.AgentExecution{
|
||||
ID: execution.id,
|
||||
TaskID: execution.taskID,
|
||||
AgentProfileID: execution.agentProfileID,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAgentTypes returns available agent types
|
||||
func (s *SimulatedAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
|
||||
return []*v1.AgentType{
|
||||
{
|
||||
ID: "augment-agent",
|
||||
Name: "Augment Agent",
|
||||
Description: "Simulated Augment agent for testing",
|
||||
DockerImage: "test/augment-agent",
|
||||
DockerTag: "test",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "auggie-cli",
|
||||
Name: "Auggie CLI",
|
||||
Description: "Simulated Auggie CLI for testing",
|
||||
DockerImage: "test/auggie-cli",
|
||||
DockerTag: "test",
|
||||
Enabled: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PromptAgent sends a follow-up prompt to a running agent
|
||||
func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*executor.PromptResult, error) {
|
||||
s.mu.Lock()
|
||||
@@ -429,11 +384,6 @@ func (s *SimulatedAgentManagerClient) Close() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
// GetRecoveredExecutions returns recovered executions (none for simulated agent)
|
||||
func (s *SimulatedAgentManagerClient) GetRecoveredExecutions() []executor.RecoveredExecutionInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAgentRunningForSession checks if a simulated agent is running for a session
|
||||
func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
||||
s.mu.Lock()
|
||||
@@ -447,10 +397,6 @@ func (s *SimulatedAgentManagerClient) IsAgentRunningForSession(ctx context.Conte
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SimulatedAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAgent cancels the current agent turn for a session
|
||||
func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID string) error {
|
||||
s.logger.Info("simulated: cancelling agent turn",
|
||||
@@ -458,25 +404,6 @@ func (s *SimulatedAgentManagerClient) CancelAgent(ctx context.Context, sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
func (s *SimulatedAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
agentTypes, _ := s.ListAgentTypes(ctx)
|
||||
for _, at := range agentTypes {
|
||||
if at.ID == agentID {
|
||||
return at, nil
|
||||
}
|
||||
}
|
||||
// Return a default agent type for testing
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Simulated Agent",
|
||||
Description: "Simulated agent for testing",
|
||||
DockerImage: "test/agent",
|
||||
DockerTag: "test",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
return &executor.AgentProfileInfo{
|
||||
@@ -587,24 +514,8 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
// Start hub
|
||||
go gateway.Hub.Run(ctx)
|
||||
|
||||
// Wire stream handler to broadcast agent events to WebSocket clients
|
||||
orchestratorSvc.RegisterStreamHandler(func(payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload == nil || payload.Data == nil {
|
||||
return
|
||||
}
|
||||
action := "agent." + payload.Data.Type
|
||||
notification, _ := ws.NewNotification(action, map[string]interface{}{
|
||||
"task_id": payload.TaskID,
|
||||
"session_id": payload.SessionID,
|
||||
"type": payload.Data.Type,
|
||||
"text": payload.Data.Text,
|
||||
"tool_call_id": payload.Data.ToolCallID,
|
||||
"tool_name": payload.Data.ToolName,
|
||||
"tool_status": payload.Data.ToolStatus,
|
||||
"timestamp": payload.Timestamp,
|
||||
})
|
||||
gateway.Hub.BroadcastToSession(payload.SessionID, notification)
|
||||
})
|
||||
// Register task notifications to broadcast events to WebSocket clients
|
||||
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
|
||||
|
||||
// Start orchestrator
|
||||
require.NoError(t, orchestratorSvc.Start(ctx))
|
||||
@@ -716,10 +627,13 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st
|
||||
type OrchestratorWSClient struct {
|
||||
conn *websocket.Conn
|
||||
t *testing.T
|
||||
messages chan *ws.Message
|
||||
notifications chan *ws.Message
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
// pending tracks in-flight requests: request ID -> response channel
|
||||
pending map[string]chan *ws.Message
|
||||
// send is the channel for outgoing messages (serialized through writePump)
|
||||
send chan []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewOrchestratorWSClient creates a WebSocket connection to the test server
|
||||
@@ -739,12 +653,14 @@ func NewOrchestratorWSClient(t *testing.T, serverURL string) *OrchestratorWSClie
|
||||
client := &OrchestratorWSClient{
|
||||
conn: conn,
|
||||
t: t,
|
||||
messages: make(chan *ws.Message, 100),
|
||||
notifications: make(chan *ws.Message, 100),
|
||||
done: make(chan struct{}),
|
||||
pending: make(map[string]chan *ws.Message),
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
go client.readPump()
|
||||
go client.writePump()
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -783,16 +699,32 @@ func (c *OrchestratorWSClient) readPump() {
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case c.messages <- &msg:
|
||||
default:
|
||||
// Route response to the pending request by ID
|
||||
c.mu.Lock()
|
||||
ch, ok := c.pending[msg.ID]
|
||||
c.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- &msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump serializes all writes to the WebSocket connection
|
||||
func (c *OrchestratorWSClient) writePump() {
|
||||
for data := range c.send {
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the WebSocket connection
|
||||
func (c *OrchestratorWSClient) Close() {
|
||||
close(c.send)
|
||||
if err := c.conn.Close(); err != nil {
|
||||
c.t.Logf("failed to close websocket: %v", err)
|
||||
}
|
||||
@@ -801,9 +733,6 @@ func (c *OrchestratorWSClient) Close() {
|
||||
|
||||
// SendRequest sends a request and waits for a response
|
||||
func (c *OrchestratorWSClient) SendRequest(id, action string, payload interface{}) (*ws.Message, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
msg, err := ws.NewRequest(id, action, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -814,20 +743,34 @@ func (c *OrchestratorWSClient) SendRequest(id, action string, payload interface{
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return nil, err
|
||||
// Create a response channel for this request
|
||||
respCh := make(chan *ws.Message, 1)
|
||||
|
||||
// Register the pending request BEFORE sending (so we don't miss the response)
|
||||
c.mu.Lock()
|
||||
c.pending[id] = respCh
|
||||
c.mu.Unlock()
|
||||
|
||||
// Ensure we clean up when done
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Send the request through the write pump (serialized)
|
||||
select {
|
||||
case c.send <- data:
|
||||
case <-time.After(5 * time.Second):
|
||||
return nil, fmt.Errorf("send buffer full")
|
||||
}
|
||||
|
||||
timeout := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case resp := <-c.messages:
|
||||
if resp.ID == id {
|
||||
return resp, nil
|
||||
}
|
||||
case <-timeout:
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
// Wait for response on our dedicated channel
|
||||
select {
|
||||
case resp := <-respCh:
|
||||
return resp, nil
|
||||
case <-time.After(30 * time.Second):
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,12 +883,8 @@ func TestOrchestratorStartTask(t *testing.T) {
|
||||
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for ACP notifications
|
||||
// Give the agent time to process
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
notifications := client.CollectNotifications(100 * time.Millisecond)
|
||||
|
||||
// Should have received some ACP messages
|
||||
assert.NotEmpty(t, notifications, "Expected ACP notifications")
|
||||
|
||||
// Check status shows active agent
|
||||
statusResp, err := client.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{})
|
||||
@@ -1059,11 +998,6 @@ func TestOrchestratorPromptTask(t *testing.T) {
|
||||
var promptPayload map[string]interface{}
|
||||
require.NoError(t, promptResp.ParsePayload(&promptPayload))
|
||||
assert.True(t, promptPayload["success"].(bool))
|
||||
|
||||
// Wait for prompt response notification
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
notifications := client.CollectNotifications(100 * time.Millisecond)
|
||||
assert.NotEmpty(t, notifications, "Expected prompt response notification")
|
||||
}
|
||||
|
||||
func TestOrchestratorCompleteTask(t *testing.T) {
|
||||
@@ -1238,22 +1172,17 @@ func TestOrchestratorACPStreaming(t *testing.T) {
|
||||
sessionID, _ := startPayload["session_id"].(string)
|
||||
require.NotEmpty(t, sessionID)
|
||||
|
||||
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
subResp, err := client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, subResp.Type, "Session subscription should succeed")
|
||||
|
||||
// Collect agent stream notifications
|
||||
// Wait for agent to process
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
notifications := client.CollectNotifications(200 * time.Millisecond)
|
||||
|
||||
// Verify we received agent stream notifications
|
||||
agentEventCount := 0
|
||||
for _, n := range notifications {
|
||||
if strings.HasPrefix(n.Action, "agent.") {
|
||||
agentEventCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, agentEventCount, 3, "Expected at least 3 agent event notifications")
|
||||
// Verify orchestrator status shows active processing
|
||||
statusResp, err := client.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, statusResp.Type)
|
||||
}
|
||||
|
||||
func TestOrchestratorMultipleClients(t *testing.T) {
|
||||
@@ -1287,20 +1216,23 @@ func TestOrchestratorMultipleClients(t *testing.T) {
|
||||
sessionID, _ := startPayload["session_id"].(string)
|
||||
require.NotEmpty(t, sessionID)
|
||||
|
||||
_, err = client1.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
// Both clients subscribe to the session
|
||||
subResp1, err := client1.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
require.NoError(t, err)
|
||||
_, err = client2.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
assert.Equal(t, ws.MessageTypeResponse, subResp1.Type, "Client 1 subscription should succeed")
|
||||
|
||||
subResp2, err := client2.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, subResp2.Type, "Client 2 subscription should succeed")
|
||||
|
||||
// Both clients should receive notifications
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
// Verify both clients can query orchestrator status
|
||||
status1, err := client1.SendRequest("status-1", ws.ActionOrchestratorStatus, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, status1.Type)
|
||||
|
||||
notifications1 := client1.CollectNotifications(100 * time.Millisecond)
|
||||
notifications2 := client2.CollectNotifications(100 * time.Millisecond)
|
||||
|
||||
// Both should have received notifications
|
||||
assert.NotEmpty(t, notifications1, "Client 1 should receive notifications")
|
||||
assert.NotEmpty(t, notifications2, "Client 2 should receive notifications")
|
||||
status2, err := client2.SendRequest("status-2", ws.ActionOrchestratorStatus, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, status2.Type)
|
||||
}
|
||||
|
||||
func TestOrchestratorErrorHandling(t *testing.T) {
|
||||
|
||||
@@ -186,25 +186,3 @@ func (c *Controller) CancelAgent(ctx context.Context, req dto.CancelAgentRequest
|
||||
SessionID: req.SessionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTaskExecution returns the execution state for a task
|
||||
func (c *Controller) GetTaskExecution(ctx context.Context, req dto.GetTaskExecutionRequest) (dto.TaskExecutionResponse, error) {
|
||||
execution, exists := c.service.GetTaskExecution(req.TaskID)
|
||||
if !exists {
|
||||
return dto.TaskExecutionResponse{
|
||||
HasExecution: false,
|
||||
TaskID: req.TaskID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return dto.TaskExecutionResponse{
|
||||
HasExecution: true,
|
||||
TaskID: execution.TaskID,
|
||||
AgentExecutionID: execution.AgentExecutionID,
|
||||
AgentProfileID: execution.AgentProfileID,
|
||||
TaskSessionID: execution.SessionID,
|
||||
State: string(execution.SessionState),
|
||||
Progress: execution.Progress,
|
||||
StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -158,23 +158,6 @@ type PermissionRespondResponse struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
}
|
||||
|
||||
// GetTaskExecutionRequest is the payload for task.execution
|
||||
type GetTaskExecutionRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// TaskExecutionResponse is the response for task.execution
|
||||
type TaskExecutionResponse struct {
|
||||
HasExecution bool `json:"has_execution"`
|
||||
TaskID string `json:"task_id"`
|
||||
AgentExecutionID string `json:"agent_execution_id,omitempty"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
TaskSessionID string `json:"session_id,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
}
|
||||
|
||||
// CancelAgentRequest is the payload for the agent.cancel WebSocket action.
|
||||
type CancelAgentRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
// Package orchestrator provides event handler methods for the orchestrator service.
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/orchestrator/watcher"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
|
||||
func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPSessionEventData) {
|
||||
if data.SessionID == "" || data.ACPSessionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, data.SessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load task session for ACP session update",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
resumable := true
|
||||
if session.ExecutorID != "" {
|
||||
if executor, err := s.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil {
|
||||
resumable = executor.Resumable
|
||||
}
|
||||
}
|
||||
|
||||
running := &models.ExecutorRunning{
|
||||
ID: session.ID,
|
||||
SessionID: session.ID,
|
||||
TaskID: session.TaskID,
|
||||
ExecutorID: session.ExecutorID,
|
||||
Status: "ready",
|
||||
Resumable: resumable,
|
||||
ResumeToken: data.ACPSessionID,
|
||||
AgentExecutionID: session.AgentExecutionID,
|
||||
ContainerID: session.ContainerID,
|
||||
}
|
||||
if len(session.Worktrees) > 0 {
|
||||
running.WorktreeID = session.Worktrees[0].WorktreeID
|
||||
running.WorktreePath = session.Worktrees[0].WorktreePath
|
||||
running.WorktreeBranch = session.Worktrees[0].WorktreeBranch
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertExecutorRunning(ctx, running); err != nil {
|
||||
s.logger.Warn("failed to persist resume token for session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("stored resume token for task session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("resume_token", data.ACPSessionID))
|
||||
}
|
||||
|
||||
// handleAgentCompleted handles agent completion events
|
||||
func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) {
|
||||
s.logger.Info("handling agent completed",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("agent_execution_id", data.AgentExecutionID))
|
||||
|
||||
// Update scheduler and remove from queue
|
||||
s.scheduler.HandleTaskCompleted(data.TaskID, true)
|
||||
s.scheduler.RemoveTask(data.TaskID)
|
||||
|
||||
// Move task to REVIEW state for user review
|
||||
// The user can then send a follow-up (moves back to IN_PROGRESS) or mark as COMPLETED
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
|
||||
s.logger.Error("failed to update task state to REVIEW",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to REVIEW state after agent completion",
|
||||
zap.String("task_id", data.TaskID))
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentFailed handles agent failure events
|
||||
func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) {
|
||||
s.logger.Warn("handling agent failed",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("agent_execution_id", data.AgentExecutionID),
|
||||
zap.String("error_message", data.ErrorMessage))
|
||||
|
||||
// Trigger retry logic
|
||||
s.scheduler.HandleTaskCompleted(data.TaskID, false)
|
||||
if !s.scheduler.RetryTask(data.TaskID) {
|
||||
s.logger.Error("task failed and retry limit exceeded",
|
||||
zap.String("task_id", data.TaskID))
|
||||
|
||||
// Move task to REVIEW state even on failure - user can decide to retry or close
|
||||
// This maintains the review cycle: user reviews the failure and decides next steps
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
|
||||
s.logger.Error("failed to update task state to REVIEW after failure",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to REVIEW state after failure (for user review)",
|
||||
zap.String("task_id", data.TaskID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentStreamEvent handles agent stream events (tool calls, message chunks, etc.)
|
||||
func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload == nil || payload.Data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
taskID := payload.TaskID
|
||||
sessionID := payload.SessionID
|
||||
eventType := payload.Data.Type
|
||||
|
||||
s.logger.Debug("handling agent stream event",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("event_type", eventType))
|
||||
|
||||
// Handle different event types
|
||||
switch eventType {
|
||||
case "message_streaming":
|
||||
s.handleMessageStreamingEvent(ctx, payload)
|
||||
|
||||
case "tool_call":
|
||||
s.saveAgentTextIfPresent(ctx, payload)
|
||||
s.handleToolCallEvent(ctx, payload)
|
||||
|
||||
case "tool_update":
|
||||
s.handleToolUpdateEvent(ctx, payload)
|
||||
|
||||
case "complete":
|
||||
s.saveAgentTextIfPresent(ctx, payload)
|
||||
s.completeTurnForSession(ctx, sessionID)
|
||||
s.setSessionWaitingForInput(ctx, taskID, sessionID)
|
||||
|
||||
case "error":
|
||||
// Handle error events
|
||||
if sessionID != "" && s.messageCreator != nil {
|
||||
if err := s.messageCreator.CreateSessionMessage(
|
||||
ctx,
|
||||
taskID,
|
||||
payload.Data.Error,
|
||||
sessionID,
|
||||
string(v1.MessageTypeError),
|
||||
s.getActiveTurnID(sessionID),
|
||||
map[string]interface{}{
|
||||
"provider": "agent",
|
||||
"provider_agent": payload.AgentID,
|
||||
},
|
||||
false,
|
||||
); err != nil {
|
||||
s.logger.Error("failed to create error message",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
// Complete the turn since the agent errored
|
||||
s.completeTurnForSession(ctx, sessionID)
|
||||
|
||||
case "session_status":
|
||||
// Handle session status events (resumed vs new session)
|
||||
if sessionID != "" && s.messageCreator != nil {
|
||||
var statusMsg string
|
||||
if payload.Data.SessionStatus == "resumed" {
|
||||
statusMsg = "Session resumed"
|
||||
} else {
|
||||
statusMsg = "New session started"
|
||||
}
|
||||
if err := s.messageCreator.CreateSessionMessage(
|
||||
ctx,
|
||||
taskID,
|
||||
statusMsg,
|
||||
sessionID,
|
||||
string(v1.MessageTypeStatus),
|
||||
s.getActiveTurnID(sessionID),
|
||||
nil,
|
||||
false,
|
||||
); err != nil {
|
||||
s.logger.Error("failed to create session status message",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolCallEvent handles tool_call events and creates messages
|
||||
func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload.SessionID == "" {
|
||||
s.logger.Warn("missing session_id for tool_call",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("tool_call_id", payload.Data.ToolCallID))
|
||||
return
|
||||
}
|
||||
|
||||
if s.messageCreator != nil {
|
||||
if err := s.messageCreator.CreateToolCallMessage(
|
||||
ctx,
|
||||
payload.TaskID,
|
||||
payload.Data.ToolCallID,
|
||||
payload.Data.ToolTitle,
|
||||
payload.Data.ToolStatus,
|
||||
payload.SessionID,
|
||||
s.getActiveTurnID(payload.SessionID),
|
||||
payload.Data.ToolArgs,
|
||||
); err != nil {
|
||||
s.logger.Error("failed to create tool call message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("tool_call_id", payload.Data.ToolCallID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("created tool call message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("tool_call_id", payload.Data.ToolCallID))
|
||||
}
|
||||
|
||||
s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", false)
|
||||
}
|
||||
}
|
||||
|
||||
// saveAgentTextIfPresent saves any accumulated agent text as an agent message
|
||||
func (s *Service) saveAgentTextIfPresent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload.Data.Text == "" || payload.SessionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if s.messageCreator != nil {
|
||||
if err := s.messageCreator.CreateAgentMessage(ctx, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil {
|
||||
s.logger.Error("failed to create agent message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("created agent message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.Int("message_length", len(payload.Data.Text)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMessageStreamingEvent handles streaming message events for real-time text updates.
|
||||
// It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
|
||||
func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload.Data.Text == "" || payload.SessionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if s.messageCreator == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// MessageID is pre-generated by the manager to avoid race conditions
|
||||
messageID := payload.Data.MessageID
|
||||
if messageID == "" {
|
||||
s.logger.Warn("streaming message event missing message ID",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("session_id", payload.SessionID))
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Data.IsAppend {
|
||||
// Append to existing message
|
||||
if err := s.messageCreator.AppendAgentMessage(ctx, messageID, payload.Data.Text); err != nil {
|
||||
s.logger.Error("failed to append to streaming message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("message_id", messageID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("appended to streaming message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("message_id", messageID),
|
||||
zap.Int("content_length", len(payload.Data.Text)))
|
||||
}
|
||||
} else {
|
||||
// Create new streaming message with the pre-generated ID
|
||||
if err := s.messageCreator.CreateAgentMessageStreaming(ctx, messageID, payload.TaskID, payload.Data.Text, payload.SessionID, s.getActiveTurnID(payload.SessionID)); err != nil {
|
||||
s.logger.Error("failed to create streaming message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("message_id", messageID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("created streaming message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("session_id", payload.SessionID),
|
||||
zap.String("message_id", messageID),
|
||||
zap.Int("content_length", len(payload.Data.Text)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolUpdateEvent handles tool_update events and updates messages
|
||||
func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload.SessionID == "" {
|
||||
s.logger.Warn("missing session_id for tool_update",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("tool_call_id", payload.Data.ToolCallID))
|
||||
return
|
||||
}
|
||||
|
||||
// Only update message when tool call completes or errors
|
||||
switch payload.Data.ToolStatus {
|
||||
case "complete", "completed", "error", "failed":
|
||||
if s.messageCreator != nil {
|
||||
result := ""
|
||||
if payload.Data.ToolResult != nil {
|
||||
if str, ok := payload.Data.ToolResult.(string); ok {
|
||||
result = str
|
||||
}
|
||||
}
|
||||
if err := s.messageCreator.UpdateToolCallMessage(
|
||||
ctx,
|
||||
payload.TaskID,
|
||||
payload.Data.ToolCallID,
|
||||
payload.Data.ToolStatus,
|
||||
result,
|
||||
payload.SessionID,
|
||||
); err != nil {
|
||||
s.logger.Warn("failed to update tool call message",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("tool_call_id", payload.Data.ToolCallID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
s.updateTaskSessionState(ctx, payload.TaskID, payload.SessionID, models.TaskSessionStateRunning, "", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID string, nextState models.TaskSessionState, errorMessage string, allowWakeFromWaiting bool) {
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if session.State == models.TaskSessionStateWaitingForInput && nextState == models.TaskSessionStateRunning && !allowWakeFromWaiting {
|
||||
return
|
||||
}
|
||||
oldState := session.State
|
||||
switch session.State {
|
||||
case models.TaskSessionStateCompleted, models.TaskSessionStateFailed, models.TaskSessionStateCancelled:
|
||||
return
|
||||
}
|
||||
if session.State == nextState {
|
||||
return
|
||||
}
|
||||
if err := s.repo.UpdateTaskSessionState(ctx, sessionID, nextState, errorMessage); err != nil {
|
||||
s.logger.Error("failed to update task session state",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("state", string(nextState)),
|
||||
zap.Error(err))
|
||||
}
|
||||
s.logger.Debug("task session state updated",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("old_state", string(oldState)),
|
||||
zap.String("new_state", string(nextState)))
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(events.TaskSessionStateChanged, "task-session", map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"old_state": string(oldState),
|
||||
"new_state": string(nextState),
|
||||
"agent_profile_id": session.AgentProfileID,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setSessionWaitingForInput(ctx context.Context, taskID, sessionID string) {
|
||||
s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateWaitingForInput, "", false)
|
||||
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview); err != nil {
|
||||
s.logger.Error("failed to update task state to REVIEW",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to REVIEW state",
|
||||
zap.String("task_id", taskID))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setSessionRunning(ctx context.Context, taskID, sessionID string) {
|
||||
s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateRunning, "", true)
|
||||
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
|
||||
s.logger.Error("failed to update task state to IN_PROGRESS",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to IN_PROGRESS state",
|
||||
zap.String("task_id", taskID))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGitStatusUpdated handles git status updates and persists them to agent session metadata
|
||||
func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitStatusData) {
|
||||
s.logger.Debug("handling git status update",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("branch", data.Branch))
|
||||
|
||||
if data.TaskSessionID == "" {
|
||||
s.logger.Debug("missing session_id for git status update",
|
||||
zap.String("task_id", data.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID)
|
||||
if err != nil {
|
||||
s.logger.Debug("no task session for git status update",
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Update session metadata with git status
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
session.Metadata["git_status"] = map[string]interface{}{
|
||||
"branch": data.Branch,
|
||||
"remote_branch": data.RemoteBranch,
|
||||
"modified": data.Modified,
|
||||
"added": data.Added,
|
||||
"deleted": data.Deleted,
|
||||
"untracked": data.Untracked,
|
||||
"renamed": data.Renamed,
|
||||
"ahead": data.Ahead,
|
||||
"behind": data.Behind,
|
||||
"files": data.Files,
|
||||
"timestamp": data.Timestamp,
|
||||
}
|
||||
|
||||
// Persist to database asynchronously
|
||||
go func() {
|
||||
if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil {
|
||||
s.logger.Error("failed to update agent session with git status",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("persisted git status to agent session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleContextWindowUpdated handles context window updates and persists them to session metadata
|
||||
func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) {
|
||||
s.logger.Debug("handling context window update",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Int64("size", data.ContextWindowSize),
|
||||
zap.Int64("used", data.ContextWindowUsed))
|
||||
|
||||
if data.TaskSessionID == "" {
|
||||
s.logger.Debug("missing session_id for context window update",
|
||||
zap.String("task_id", data.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID)
|
||||
if err != nil {
|
||||
s.logger.Debug("no task session for context window update",
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Update session metadata with context window info
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
contextWindowData := map[string]interface{}{
|
||||
"size": data.ContextWindowSize,
|
||||
"used": data.ContextWindowUsed,
|
||||
"remaining": data.ContextWindowRemaining,
|
||||
"efficiency": data.ContextEfficiency,
|
||||
}
|
||||
session.Metadata["context_window"] = contextWindowData
|
||||
|
||||
// Persist to database asynchronously
|
||||
go func() {
|
||||
if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil {
|
||||
s.logger.Error("failed to update session with context window",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("persisted context window to session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID))
|
||||
}
|
||||
}()
|
||||
|
||||
// Broadcast session state change with metadata so frontend can update
|
||||
// This uses the existing session.state_changed event with metadata included
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
map[string]interface{}{
|
||||
"task_id": data.TaskID,
|
||||
"session_id": session.ID,
|
||||
"new_state": string(session.State),
|
||||
"agent_profile_id": session.AgentProfileID,
|
||||
"metadata": map[string]interface{}{
|
||||
"context_window": contextWindowData,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// handlePermissionRequest handles permission request events and saves as message
|
||||
func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.PermissionRequestData) {
|
||||
s.logger.Debug("handling permission request",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("pending_id", data.PendingID),
|
||||
zap.String("title", data.Title))
|
||||
|
||||
if data.TaskSessionID == "" {
|
||||
s.logger.Warn("missing session_id for permission_request",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("pending_id", data.PendingID))
|
||||
return
|
||||
}
|
||||
|
||||
s.setSessionWaitingForInput(ctx, data.TaskID, data.TaskSessionID)
|
||||
|
||||
if s.messageCreator != nil {
|
||||
_, err := s.messageCreator.CreatePermissionRequestMessage(
|
||||
ctx,
|
||||
data.TaskID,
|
||||
data.TaskSessionID,
|
||||
data.PendingID,
|
||||
data.ToolCallID,
|
||||
data.Title,
|
||||
s.getActiveTurnID(data.TaskSessionID),
|
||||
data.Options,
|
||||
data.ActionType,
|
||||
data.ActionDetails,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create permission request message",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("pending_id", data.PendingID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("created permission request message",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("pending_id", data.PendingID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,6 @@ type AgentManagerClient interface {
|
||||
// StopAgent stops a running agent
|
||||
StopAgent(ctx context.Context, agentExecutionID string, force bool) error
|
||||
|
||||
// GetAgentStatus returns the status of an agent execution
|
||||
GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error)
|
||||
|
||||
// ListAgentTypes returns available agent types
|
||||
ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error)
|
||||
|
||||
// PromptAgent sends a prompt to a running agent
|
||||
// Returns PromptResult indicating if the agent needs input
|
||||
PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*PromptResult, error)
|
||||
@@ -58,20 +52,10 @@ type AgentManagerClient interface {
|
||||
// RespondToPermission sends a response to a permission request
|
||||
RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error
|
||||
|
||||
// GetRecoveredExecutions returns executions recovered from Docker during startup
|
||||
GetRecoveredExecutions() []RecoveredExecutionInfo
|
||||
|
||||
// IsAgentRunningForSession checks if an agent is actually running for a session
|
||||
// This probes the actual agent (Docker container or standalone process) rather than relying on cached state
|
||||
IsAgentRunningForSession(ctx context.Context, sessionID string) bool
|
||||
|
||||
// CleanupStaleExecutionBySessionID removes a stale agent execution from tracking without trying to stop it.
|
||||
// This is used when we detect the agent process has stopped but the execution is still tracked.
|
||||
CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error
|
||||
|
||||
// GetAgentType returns the agent type configuration
|
||||
GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error)
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
|
||||
}
|
||||
@@ -197,11 +181,6 @@ func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, lo
|
||||
}
|
||||
}
|
||||
|
||||
// SetWorktreeEnabled enables or disables worktree mode
|
||||
func (e *Executor) SetWorktreeEnabled(enabled bool) {
|
||||
e.worktreeEnabled = enabled
|
||||
}
|
||||
|
||||
func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]string) map[string]string {
|
||||
if e.shellPrefs == nil {
|
||||
return env
|
||||
@@ -222,15 +201,6 @@ func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]st
|
||||
return env
|
||||
}
|
||||
|
||||
// RecoveredExecutionInfo contains info about an execution recovered from Docker
|
||||
type RecoveredExecutionInfo struct {
|
||||
ExecutionID string
|
||||
TaskID string
|
||||
SessionID string
|
||||
ContainerID string
|
||||
AgentProfileID string
|
||||
}
|
||||
|
||||
// Execute starts agent execution for a task
|
||||
func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) {
|
||||
return e.ExecuteWithProfile(ctx, task, "", task.Description)
|
||||
@@ -979,30 +949,6 @@ func (e *Executor) RespondToPermission(ctx context.Context, sessionID, pendingID
|
||||
return e.agentManager.RespondToPermissionBySessionID(ctx, sessionID, pendingID, optionID, cancelled)
|
||||
}
|
||||
|
||||
// GetExecution returns the current execution state for a task (returns most recent active session)
|
||||
func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) {
|
||||
ctx := context.Background()
|
||||
const startupGracePeriod = 30 * time.Second
|
||||
|
||||
// Load from database
|
||||
session, err := e.repo.GetActiveTaskSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Verify the agent is actually running by probing the lifecycle manager
|
||||
// This handles the case where backend was restarted and DB has stale "running" sessions
|
||||
if session.ID == "" || !e.agentManager.IsAgentRunningForSession(ctx, session.ID) {
|
||||
if (session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning) &&
|
||||
time.Since(session.UpdatedAt) < startupGracePeriod {
|
||||
return FromTaskSession(session), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return FromTaskSession(session), true
|
||||
}
|
||||
|
||||
// GetExecutionBySession returns the execution state for a specific session
|
||||
func (e *Executor) GetExecutionBySession(sessionID string) (*TaskExecution, bool) {
|
||||
ctx := context.Background()
|
||||
@@ -1029,33 +975,6 @@ func (e *Executor) GetExecutionBySession(sessionID string) (*TaskExecution, bool
|
||||
return FromTaskSession(session), true
|
||||
}
|
||||
|
||||
// GetExecutionWithContext returns the current execution state for a task with context (returns most recent active session)
|
||||
// This is a read-only check - it does NOT cancel sessions as a side effect.
|
||||
func (e *Executor) GetExecutionWithContext(ctx context.Context, taskID string) (*TaskExecution, bool) {
|
||||
const startupGracePeriod = 30 * time.Second
|
||||
|
||||
// Load from database
|
||||
session, err := e.repo.GetActiveTaskSessionByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if session.ID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Verify the agent is actually running by probing the lifecycle manager
|
||||
if !e.agentManager.IsAgentRunningForSession(ctx, session.ID) {
|
||||
// Allow a grace period for sessions that are starting up
|
||||
if (session.State == models.TaskSessionStateStarting || session.State == models.TaskSessionStateRunning) &&
|
||||
time.Since(session.UpdatedAt) < startupGracePeriod {
|
||||
return FromTaskSession(session), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return FromTaskSession(session), true
|
||||
}
|
||||
|
||||
// ListExecutions returns all active executions
|
||||
func (e *Executor) ListExecutions() []*TaskExecution {
|
||||
ctx := context.Background()
|
||||
@@ -1087,29 +1006,6 @@ func (e *Executor) CanExecute() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateProgressBySession updates the progress of an execution by session ID
|
||||
func (e *Executor) UpdateProgressBySession(ctx context.Context, sessionID string, progress int, state v1.TaskSessionState) {
|
||||
e.logger.Debug("updating execution progress",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("progress", progress),
|
||||
zap.String("state", string(state)))
|
||||
|
||||
// Update database asynchronously (don't block on this)
|
||||
go func() {
|
||||
session, err := e.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
session.Progress = progress
|
||||
session.State = models.TaskSessionState(state)
|
||||
if err := e.repo.UpdateTaskSession(ctx, session); err != nil {
|
||||
e.logger.Error("failed to update agent session progress in database",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// MarkCompletedBySession marks an execution as completed by session ID
|
||||
func (e *Executor) MarkCompletedBySession(ctx context.Context, sessionID string, state v1.TaskSessionState) {
|
||||
e.logger.Info("execution completed",
|
||||
@@ -1164,141 +1060,3 @@ func cloneMetadata(src map[string]interface{}) map[string]interface{} {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MockAgentManagerClient is a placeholder implementation
|
||||
type MockAgentManagerClient struct {
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewMockAgentManagerClient creates a new mock agent manager client
|
||||
func NewMockAgentManagerClient(log *logger.Logger) *MockAgentManagerClient {
|
||||
return &MockAgentManagerClient{
|
||||
logger: log.WithFields(zap.String("component", "mock_agent_manager")),
|
||||
}
|
||||
}
|
||||
|
||||
// LaunchAgent mocks launching an agent container
|
||||
func (m *MockAgentManagerClient) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
m.logger.Info("mock: launching agent",
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("agent_profile_id", req.AgentProfileID),
|
||||
zap.String("repository_url", req.RepositoryURL),
|
||||
zap.String("branch", req.Branch))
|
||||
|
||||
return &LaunchAgentResponse{
|
||||
AgentExecutionID: uuid.New().String(),
|
||||
ContainerID: "mock-container-" + uuid.New().String()[:8],
|
||||
Status: v1.AgentStatusStarting,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartAgentProcess mocks starting the agent subprocess
|
||||
func (m *MockAgentManagerClient) StartAgentProcess(ctx context.Context, agentExecutionID string) error {
|
||||
m.logger.Info("mock: starting agent process",
|
||||
zap.String("agent_execution_id", agentExecutionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAgent mocks stopping an agent
|
||||
func (m *MockAgentManagerClient) StopAgent(ctx context.Context, agentExecutionID string, force bool) error {
|
||||
m.logger.Info("mock: stopping agent",
|
||||
zap.String("agent_execution_id", agentExecutionID),
|
||||
zap.Bool("force", force))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentStatus mocks getting agent status
|
||||
func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) {
|
||||
m.logger.Info("mock: getting agent status",
|
||||
zap.String("agent_execution_id", agentExecutionID))
|
||||
|
||||
return &v1.AgentExecution{
|
||||
ID: agentExecutionID,
|
||||
Status: v1.AgentStatusRunning,
|
||||
AgentProfileID: "mock-agent",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAgentTypes mocks listing available agent types
|
||||
func (m *MockAgentManagerClient) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
|
||||
m.logger.Info("mock: listing agent types")
|
||||
|
||||
return []*v1.AgentType{
|
||||
{
|
||||
ID: "auggie-cli",
|
||||
Name: "Auggie CLI Agent",
|
||||
Description: "CLI-based agent for code tasks",
|
||||
DockerImage: "auggie-cli",
|
||||
DockerTag: "latest",
|
||||
Enabled: true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PromptAgent mocks sending a prompt to an agent
|
||||
func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*PromptResult, error) {
|
||||
m.logger.Info("mock: prompting agent",
|
||||
zap.String("agent_execution_id", agentExecutionID),
|
||||
zap.Int("prompt_length", len(prompt)))
|
||||
return &PromptResult{StopReason: "end_turn"}, nil
|
||||
}
|
||||
|
||||
// RespondToPermissionBySessionID mocks responding to a permission request
|
||||
func (m *MockAgentManagerClient) RespondToPermissionBySessionID(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error {
|
||||
m.logger.Info("mock: responding to permission",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("option_id", optionID),
|
||||
zap.Bool("cancelled", cancelled))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAgent mocks cancelling the current agent turn
|
||||
func (m *MockAgentManagerClient) CancelAgent(ctx context.Context, sessionID string) error {
|
||||
m.logger.Info("mock: cancelling agent turn",
|
||||
zap.String("session_id", sessionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRecoveredExecutions mocks getting recovered executions (returns empty for mock)
|
||||
func (m *MockAgentManagerClient) GetRecoveredExecutions() []RecoveredExecutionInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAgentRunningForSession mocks checking if an agent is running (always returns false for mock)
|
||||
func (m *MockAgentManagerClient) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CleanupStaleExecutionBySessionID mocks cleaning up a stale execution (no-op for mock)
|
||||
func (m *MockAgentManagerClient) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentType mocks getting an agent type configuration
|
||||
func (m *MockAgentManagerClient) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
m.logger.Info("mock: getting agent type",
|
||||
zap.String("agent_id", agentID))
|
||||
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Mock Agent",
|
||||
Description: "Mock agent type for testing",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAgentProfile mocks resolving an agent profile
|
||||
func (m *MockAgentManagerClient) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
||||
m.logger.Info("mock: resolving agent profile",
|
||||
zap.String("profile_id", profileID))
|
||||
|
||||
return &AgentProfileInfo{
|
||||
ProfileID: profileID,
|
||||
ProfileName: "Mock Profile",
|
||||
AgentID: "mock-agent",
|
||||
AgentName: "mock",
|
||||
Model: "mock-model",
|
||||
AutoApprove: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
|
||||
d.RegisterFunc(ws.ActionOrchestratorPrompt, h.wsPromptTask)
|
||||
d.RegisterFunc(ws.ActionOrchestratorComplete, h.wsCompleteTask)
|
||||
d.RegisterFunc(ws.ActionPermissionRespond, h.wsRespondToPermission)
|
||||
d.RegisterFunc(ws.ActionTaskExecution, h.wsGetTaskExecution)
|
||||
d.RegisterFunc(ws.ActionTaskSessionResume, h.wsResumeTaskSession)
|
||||
d.RegisterFunc(ws.ActionTaskSessionStatus, h.wsGetTaskSessionStatus)
|
||||
d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent)
|
||||
@@ -269,30 +268,6 @@ func (h *Handlers) wsRespondToPermission(ctx context.Context, msg *ws.Message) (
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsGetTaskExecutionRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsGetTaskExecution(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetTaskExecutionRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid request payload", nil)
|
||||
}
|
||||
|
||||
if req.TaskID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "task_id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.GetTaskExecution(ctx, dto.GetTaskExecutionRequest{
|
||||
TaskID: req.TaskID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get task execution", zap.String("task_id", req.TaskID), zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task execution: "+err.Error(), nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsGetTaskSessionStatusRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskSessionID string `json:"session_id"`
|
||||
|
||||
@@ -121,17 +121,6 @@ func (q *TaskQueue) Dequeue() *QueuedTask {
|
||||
return qt
|
||||
}
|
||||
|
||||
// Peek returns the highest priority task without removing it
|
||||
func (q *TaskQueue) Peek() *QueuedTask {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
|
||||
if len(q.heap) == 0 {
|
||||
return nil
|
||||
}
|
||||
return q.heap[0]
|
||||
}
|
||||
|
||||
// Remove removes a specific task from the queue
|
||||
func (q *TaskQueue) Remove(taskID string) bool {
|
||||
q.mu.Lock()
|
||||
@@ -147,30 +136,6 @@ func (q *TaskQueue) Remove(taskID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdatePriority updates the priority of a task in the queue
|
||||
func (q *TaskQueue) UpdatePriority(taskID string, newPriority int) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
qt, exists := q.taskMap[taskID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
qt.Priority = newPriority
|
||||
heap.Fix(&q.heap, qt.index)
|
||||
return true
|
||||
}
|
||||
|
||||
// Contains checks if a task is in the queue
|
||||
func (q *TaskQueue) Contains(taskID string) bool {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
|
||||
_, exists := q.taskMap[taskID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// Len returns the number of tasks in the queue
|
||||
func (q *TaskQueue) Len() int {
|
||||
q.mu.RLock()
|
||||
@@ -196,13 +161,3 @@ func (q *TaskQueue) List() []*QueuedTask {
|
||||
copy(result, q.heap)
|
||||
return result
|
||||
}
|
||||
|
||||
// Clear removes all tasks from the queue
|
||||
func (q *TaskQueue) Clear() {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.heap = make(taskHeap, 0)
|
||||
q.taskMap = make(map[string]*QueuedTask)
|
||||
heap.Init(&q.heap)
|
||||
}
|
||||
|
||||
@@ -121,30 +121,6 @@ func TestPriorityOrdering(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeek(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
// Peek on empty queue
|
||||
peeked := q.Peek()
|
||||
if peeked != nil {
|
||||
t.Errorf("expected nil from Peek on empty queue")
|
||||
}
|
||||
|
||||
_ = q.Enqueue(createTestTask("task-1", 5))
|
||||
peeked = q.Peek()
|
||||
|
||||
if peeked == nil {
|
||||
t.Fatal("Peek returned nil on non-empty queue")
|
||||
}
|
||||
if peeked.TaskID != "task-1" {
|
||||
t.Errorf("expected TaskID = task-1, got %s", peeked.TaskID)
|
||||
}
|
||||
// Verify it didn't remove the task
|
||||
if q.Len() != 1 {
|
||||
t.Errorf("Peek should not remove task from queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
@@ -158,7 +134,8 @@ func TestRemove(t *testing.T) {
|
||||
if q.Len() != 1 {
|
||||
t.Errorf("expected Len() = 1 after remove, got %d", q.Len())
|
||||
}
|
||||
if q.Contains("task-1") {
|
||||
// Verify task was removed by trying to remove again
|
||||
if q.Remove("task-1") {
|
||||
t.Error("queue should not contain removed task")
|
||||
}
|
||||
}
|
||||
@@ -171,52 +148,6 @@ func TestRemoveNonExistent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePriority(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
_ = q.Enqueue(createTestTask("task-1", 1))
|
||||
_ = q.Enqueue(createTestTask("task-2", 10))
|
||||
|
||||
// task-2 should be first initially
|
||||
peeked := q.Peek()
|
||||
if peeked.TaskID != "task-2" {
|
||||
t.Errorf("expected task-2 first initially")
|
||||
}
|
||||
|
||||
// Update task-1 to have higher priority
|
||||
updated := q.UpdatePriority("task-1", 20)
|
||||
if !updated {
|
||||
t.Error("UpdatePriority should return true for existing task")
|
||||
}
|
||||
|
||||
// Now task-1 should be first
|
||||
peeked = q.Peek()
|
||||
if peeked.TaskID != "task-1" {
|
||||
t.Errorf("expected task-1 first after priority update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePriorityNonExistent(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
updated := q.UpdatePriority("non-existent", 10)
|
||||
if updated {
|
||||
t.Error("UpdatePriority should return false for non-existent task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
_ = q.Enqueue(createTestTask("task-1", 5))
|
||||
|
||||
if !q.Contains("task-1") {
|
||||
t.Error("Contains should return true for existing task")
|
||||
}
|
||||
if q.Contains("task-2") {
|
||||
t.Error("Contains should return false for non-existent task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFull(t *testing.T) {
|
||||
q := NewTaskQueue(2)
|
||||
|
||||
@@ -248,21 +179,6 @@ func TestList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
_ = q.Enqueue(createTestTask("task-1", 5))
|
||||
_ = q.Enqueue(createTestTask("task-2", 3))
|
||||
|
||||
q.Clear()
|
||||
if q.Len() != 0 {
|
||||
t.Errorf("expected Len() = 0 after Clear, got %d", q.Len())
|
||||
}
|
||||
if q.Contains("task-1") || q.Contains("task-2") {
|
||||
t.Error("Clear should remove all tasks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlimitedQueue(t *testing.T) {
|
||||
// maxSize of 0 means unlimited
|
||||
q := NewTaskQueue(0)
|
||||
|
||||
@@ -51,17 +51,6 @@ func (m *mockAgentManager) StopAgent(ctx context.Context, agentExecutionID strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) GetAgentStatus(ctx context.Context, agentExecutionID string) (*v1.AgentExecution, error) {
|
||||
return &v1.AgentExecution{
|
||||
ID: agentExecutionID,
|
||||
Status: v1.AgentStatusRunning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, error) {
|
||||
return []*v1.AgentType{}, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) PromptAgent(ctx context.Context, agentExecutionID string, prompt string) (*executor.PromptResult, error) {
|
||||
return &executor.PromptResult{StopReason: "end_turn"}, nil
|
||||
}
|
||||
@@ -70,30 +59,14 @@ func (m *mockAgentManager) RespondToPermissionBySessionID(ctx context.Context, s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) GetRecoveredExecutions() []executor.RecoveredExecutionInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, sessionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) CleanupStaleExecutionBySessionID(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) GetAgentType(ctx context.Context, agentID string) (*v1.AgentType, error) {
|
||||
return &v1.AgentType{
|
||||
ID: agentID,
|
||||
Name: "Mock Agent",
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
return &executor.AgentProfileInfo{
|
||||
ProfileID: profileID,
|
||||
@@ -402,10 +375,10 @@ func TestPriorityOrdering(t *testing.T) {
|
||||
_ = s.EnqueueTask(createTestTask("high", 10))
|
||||
_ = s.EnqueueTask(createTestTask("medium", 5))
|
||||
|
||||
// Verify the underlying queue has correct ordering
|
||||
peeked := q.Peek()
|
||||
if peeked.TaskID != "high" {
|
||||
t.Errorf("expected highest priority task (high) first, got %s", peeked.TaskID)
|
||||
// Verify the underlying queue has correct ordering by dequeueing
|
||||
first := q.Dequeue()
|
||||
if first == nil || first.TaskID != "high" {
|
||||
t.Errorf("expected highest priority task (high) first, got %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
||||
// Package orchestrator provides the main orchestrator service that ties all components together.
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/orchestrator/dto"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/queue"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// PromptResult contains the result of a prompt operation
|
||||
type PromptResult struct {
|
||||
StopReason string // The reason the agent stopped (e.g., "end_turn")
|
||||
AgentMessage string // The agent's accumulated response message
|
||||
}
|
||||
|
||||
func validateSessionWorktrees(session *models.TaskSession) error {
|
||||
for _, wt := range session.Worktrees {
|
||||
if wt.WorktreePath == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(wt.WorktreePath); err != nil {
|
||||
return fmt.Errorf("worktree path not found: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueTask manually adds a task to the queue
|
||||
func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
|
||||
s.logger.Debug("manually enqueueing task",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("title", task.Title))
|
||||
return s.scheduler.EnqueueTask(task)
|
||||
}
|
||||
|
||||
// StartTask manually starts agent execution for a task
|
||||
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, priority int, prompt string) (*executor.TaskExecution, error) {
|
||||
s.logger.Debug("manually starting task",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("agent_profile_id", agentProfileID),
|
||||
zap.Int("priority", priority),
|
||||
zap.Int("prompt_length", len(prompt)))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// Fetch the task from the repository to get complete task info
|
||||
task, err := s.scheduler.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch task for manual start",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Override priority if provided in the request
|
||||
if priority > 0 {
|
||||
task.Priority = priority
|
||||
}
|
||||
|
||||
// Use provided prompt, fall back to task description
|
||||
effectivePrompt := prompt
|
||||
if effectivePrompt == "" {
|
||||
effectivePrompt = task.Description
|
||||
}
|
||||
|
||||
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, effectivePrompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if execution.SessionID != "" {
|
||||
s.updateTaskSessionState(ctx, taskID, execution.SessionID, models.TaskSessionStateRunning, "", true)
|
||||
// Create user message for the initial prompt
|
||||
if s.messageCreator != nil && effectivePrompt != "" {
|
||||
if err := s.messageCreator.CreateUserMessage(ctx, taskID, effectivePrompt, execution.SessionID, s.getActiveTurnID(execution.SessionID)); err != nil {
|
||||
s.logger.Error("failed to create initial user message",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateInProgress); err != nil {
|
||||
s.logger.Warn("failed to update task state to IN_PROGRESS",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// ResumeTaskSession restarts a specific task session using its stored worktree.
|
||||
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
|
||||
s.logger.Debug("resuming task session",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.TaskID != taskID {
|
||||
return nil, fmt.Errorf("task session does not belong to task")
|
||||
}
|
||||
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
|
||||
if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable {
|
||||
return nil, fmt.Errorf("session is not resumable")
|
||||
}
|
||||
if err := validateSessionWorktrees(session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
execution, err := s.executor.ResumeSession(ctx, session, true)
|
||||
if err != nil {
|
||||
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
// Preserve persisted task/session state; resume should not mutate state/columns.
|
||||
execution.SessionState = v1.TaskSessionState(session.State)
|
||||
|
||||
s.logger.Debug("task session resumed and ready for input",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// GetTaskSessionStatus returns the status of a task session including whether it's resumable
|
||||
func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) {
|
||||
s.logger.Debug("checking task session status",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
resp := dto.TaskSessionStatusResponse{
|
||||
SessionID: sessionID,
|
||||
TaskID: taskID,
|
||||
}
|
||||
|
||||
// 1. Load session from database
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
resp.Error = "session not found"
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if session.TaskID != taskID {
|
||||
resp.Error = "session does not belong to task"
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.State = string(session.State)
|
||||
resp.AgentProfileID = session.AgentProfileID
|
||||
|
||||
// Extract resume token from executor runtime state.
|
||||
var resumeToken string
|
||||
if running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID); err == nil && running != nil {
|
||||
resumeToken = running.ResumeToken
|
||||
resp.ACPSessionID = resumeToken
|
||||
if running.Resumable {
|
||||
resp.IsResumable = true
|
||||
}
|
||||
}
|
||||
|
||||
// Extract worktree info
|
||||
if len(session.Worktrees) > 0 {
|
||||
wt := session.Worktrees[0]
|
||||
if wt.WorktreePath != "" {
|
||||
resp.WorktreePath = &wt.WorktreePath
|
||||
}
|
||||
if wt.WorktreeBranch != "" {
|
||||
resp.WorktreeBranch = &wt.WorktreeBranch
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if this session's agent is running
|
||||
if exec, ok := s.executor.GetExecutionBySession(sessionID); ok && exec != nil {
|
||||
resp.IsAgentRunning = true
|
||||
resp.NeedsResume = false
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 3. Session can be resumed if it has a resume token
|
||||
if resumeToken == "" {
|
||||
resp.IsAgentRunning = false
|
||||
resp.IsResumable = false
|
||||
resp.NeedsResume = false
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 4. Additional validations for resumption
|
||||
if session.AgentProfileID == "" {
|
||||
resp.Error = "session missing agent profile"
|
||||
resp.IsResumable = false
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check if worktree exists (if one was used)
|
||||
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" {
|
||||
if _, err := os.Stat(session.Worktrees[0].WorktreePath); err != nil {
|
||||
resp.Error = "worktree not found"
|
||||
resp.IsResumable = false
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
resp.IsAgentRunning = false
|
||||
resp.NeedsResume = true
|
||||
resp.ResumeReason = "agent_not_running"
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// StopTask stops agent execution for a task (stops all active sessions for the task)
|
||||
func (s *Service) StopTask(ctx context.Context, taskID string, reason string, force bool) error {
|
||||
s.logger.Info("stopping task execution",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("reason", reason),
|
||||
zap.Bool("force", force))
|
||||
return s.executor.StopByTaskID(ctx, taskID, reason, force)
|
||||
}
|
||||
|
||||
// StopSession stops agent execution for a specific session
|
||||
func (s *Service) StopSession(ctx context.Context, sessionID string, reason string, force bool) error {
|
||||
s.logger.Info("stopping session execution",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("reason", reason),
|
||||
zap.Bool("force", force))
|
||||
return s.executor.Stop(ctx, sessionID, reason, force)
|
||||
}
|
||||
|
||||
// PromptTask sends a follow-up prompt to a running agent for a task session.
|
||||
func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string) (*PromptResult, error) {
|
||||
s.logger.Debug("sending prompt to task agent",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("prompt_length", len(prompt)))
|
||||
if sessionID == "" {
|
||||
return nil, fmt.Errorf("session_id is required")
|
||||
}
|
||||
|
||||
// Check if model switching is requested
|
||||
if model != "" {
|
||||
// Get the current session to check current model
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session for model switch: %w", err)
|
||||
}
|
||||
|
||||
// Get current model from agent profile snapshot
|
||||
if session.AgentProfileSnapshot != nil {
|
||||
if currentModel, ok := session.AgentProfileSnapshot["model"].(string); ok && currentModel != model {
|
||||
// Model switch needed - delegate to executor
|
||||
s.logger.Debug("model switch requested",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("current_model", currentModel),
|
||||
zap.String("new_model", model))
|
||||
|
||||
// Start a new turn for this prompt (model switch case)
|
||||
s.startTurnForSession(ctx, sessionID)
|
||||
|
||||
// SwitchModel will stop agent, rebuild with new model, restart, and send prompt
|
||||
switchResult, err := s.executor.SwitchModel(ctx, taskID, sessionID, model, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PromptResult{
|
||||
StopReason: switchResult.StopReason,
|
||||
AgentMessage: switchResult.AgentMessage,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.setSessionRunning(ctx, taskID, sessionID)
|
||||
// Start a new turn for this prompt
|
||||
s.startTurnForSession(ctx, sessionID)
|
||||
result, err := s.executor.Prompt(ctx, taskID, sessionID, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PromptResult{
|
||||
StopReason: result.StopReason,
|
||||
AgentMessage: result.AgentMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RespondToPermission sends a response to a permission request for a session
|
||||
func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID, optionID string, cancelled bool) error {
|
||||
s.logger.Debug("responding to permission request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("option_id", optionID),
|
||||
zap.Bool("cancelled", cancelled))
|
||||
|
||||
// Respond to the permission via agentctl
|
||||
if err := s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine status based on response
|
||||
status := "approved"
|
||||
if cancelled {
|
||||
status = "rejected"
|
||||
}
|
||||
|
||||
// Update the permission message with the new status
|
||||
if s.messageCreator != nil {
|
||||
if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, pendingID, status); err != nil {
|
||||
s.logger.Warn("failed to update permission message status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("status", status),
|
||||
zap.Error(err))
|
||||
// Don't fail the whole operation if message update fails
|
||||
}
|
||||
}
|
||||
|
||||
if !cancelled {
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load task session after permission response",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
s.setSessionRunning(ctx, session.TaskID, sessionID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAgent interrupts the current agent turn without terminating the process,
|
||||
// allowing the user to send a new prompt.
|
||||
func (s *Service) CancelAgent(ctx context.Context, sessionID string) error {
|
||||
s.logger.Debug("cancelling agent turn", zap.String("session_id", sessionID))
|
||||
|
||||
// Fetch session for state updates and message creation
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get session for cancel",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
if err := s.agentManager.CancelAgent(ctx, sessionID); err != nil {
|
||||
return fmt.Errorf("cancel agent: %w", err)
|
||||
}
|
||||
|
||||
// Transition to WAITING_FOR_INPUT so the user can send a new prompt
|
||||
if session != nil {
|
||||
s.updateTaskSessionState(ctx, session.TaskID, sessionID, models.TaskSessionStateWaitingForInput, "", true)
|
||||
}
|
||||
|
||||
// Record cancellation in the message history
|
||||
if s.messageCreator != nil && session != nil {
|
||||
metadata := map[string]interface{}{
|
||||
"cancelled": true,
|
||||
"variant": "warning",
|
||||
}
|
||||
if err := s.messageCreator.CreateSessionMessage(
|
||||
ctx,
|
||||
session.TaskID,
|
||||
"Turn cancelled by user",
|
||||
sessionID,
|
||||
string(v1.MessageTypeStatus),
|
||||
s.getActiveTurnID(sessionID),
|
||||
metadata,
|
||||
false,
|
||||
); err != nil {
|
||||
s.logger.Warn("failed to create cancel message",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the turn since the agent was cancelled
|
||||
s.completeTurnForSession(ctx, sessionID)
|
||||
|
||||
s.logger.Debug("agent turn cancelled", zap.String("session_id", sessionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteTask explicitly completes a task and stops all its agents
|
||||
func (s *Service) CompleteTask(ctx context.Context, taskID string) error {
|
||||
s.logger.Info("completing task",
|
||||
zap.String("task_id", taskID))
|
||||
|
||||
// Stop all agents for this task (which will trigger AgentCompleted events and update session states)
|
||||
if err := s.executor.StopByTaskID(ctx, taskID, "task completed by user", false); err != nil {
|
||||
// If agents are already stopped, just update the task state directly
|
||||
s.logger.Warn("failed to stop agents, updating task state directly",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Update task state to COMPLETED
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateCompleted); err != nil {
|
||||
return fmt.Errorf("failed to update task state: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("task marked as COMPLETED",
|
||||
zap.String("task_id", taskID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQueuedTasks returns tasks in the queue
|
||||
func (s *Service) GetQueuedTasks() []*queue.QueuedTask {
|
||||
return s.queue.List()
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ type AgentEventData struct {
|
||||
// ACPSessionEventData contains data from ACP session events
|
||||
type ACPSessionEventData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentExecutionID string `json:"agent_execution_id"`
|
||||
ACPSessionID string `json:"acp_session_id"`
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/worktree"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
|
||||
@@ -62,7 +62,7 @@ const (
|
||||
ActionTaskDelete = "task.delete"
|
||||
ActionTaskMove = "task.move"
|
||||
ActionTaskState = "task.state"
|
||||
ActionTaskExecution = "task.execution"
|
||||
|
||||
ActionTaskSessionList = "task.session.list"
|
||||
ActionTaskSessionResume = "task.session.resume"
|
||||
ActionTaskSessionStatus = "task.session.status"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import type { TaskSessionState, Task, TaskSession } from '@/lib/types/http';
|
||||
@@ -17,13 +17,8 @@ interface UseSessionAgentReturn {
|
||||
}
|
||||
|
||||
export function useSessionAgent(task: Task | null): UseSessionAgentReturn {
|
||||
const [isAgentRunning, setIsAgentRunning] = useState(false);
|
||||
const [isAgentLoading, setIsAgentLoading] = useState(false);
|
||||
const [taskSessionId, setAgentSessionId] = useState<string | null>(null);
|
||||
const [taskSessionState, setTaskSessionState] = useState<TaskSessionState | null>(null);
|
||||
const [worktreePath, setWorktreePath] = useState<string | null>(null);
|
||||
const [worktreeBranch, setWorktreeBranch] = useState<string | null>(null);
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const activeSession = useAppStore((state) =>
|
||||
activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null
|
||||
@@ -31,79 +26,23 @@ export function useSessionAgent(task: Task | null): UseSessionAgentReturn {
|
||||
const sessionsForTask = useAppStore((state) =>
|
||||
task?.id ? state.taskSessionsByTask.itemsByTaskId[task.id] ?? EMPTY_SESSIONS : EMPTY_SESSIONS
|
||||
);
|
||||
const wsTaskSessionState = useMemo(() => {
|
||||
if (!task?.id) return undefined;
|
||||
|
||||
// Derive session from store: prefer active session if it belongs to this task, otherwise first session for task
|
||||
const currentSession = useMemo(() => {
|
||||
if (!task?.id) return null;
|
||||
if (activeSession && activeSession.task_id === task.id) {
|
||||
return activeSession.state;
|
||||
return activeSession;
|
||||
}
|
||||
return sessionsForTask[0]?.state;
|
||||
return sessionsForTask[0] ?? null;
|
||||
}, [activeSession, sessionsForTask, task?.id]);
|
||||
|
||||
// Get worktree info from active session
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
setWorktreePath(activeSession.worktree_path ?? null);
|
||||
setWorktreeBranch(activeSession.worktree_branch ?? null);
|
||||
}
|
||||
}, [activeSession]);
|
||||
const taskSessionId = currentSession?.id ?? null;
|
||||
const taskSessionState = currentSession?.state ?? null;
|
||||
const worktreePath = currentSession?.worktree_path ?? null;
|
||||
const worktreeBranch = currentSession?.worktree_branch ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setIsAgentRunning(false);
|
||||
setAgentSessionId(null);
|
||||
setTaskSessionState(null);
|
||||
setWorktreePath(null);
|
||||
setWorktreeBranch(null);
|
||||
}, [task?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsTaskSessionState) return;
|
||||
setTaskSessionState(wsTaskSessionState as TaskSessionState);
|
||||
}, [wsTaskSessionState]);
|
||||
|
||||
// Fetch task execution status from orchestrator on mount
|
||||
useEffect(() => {
|
||||
if (!task?.id) return;
|
||||
if (connectionStatus !== 'connected') return;
|
||||
|
||||
const checkExecution = async () => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const response = await client.request<{
|
||||
has_execution: boolean;
|
||||
task_id: string;
|
||||
state?: string;
|
||||
session_id?: string;
|
||||
}>('task.execution', { task_id: task.id });
|
||||
|
||||
if (response.has_execution) {
|
||||
setIsAgentRunning(true);
|
||||
if (response.state) {
|
||||
setTaskSessionState(response.state as TaskSessionState);
|
||||
}
|
||||
if (response.session_id) {
|
||||
setAgentSessionId(response.session_id);
|
||||
}
|
||||
} else {
|
||||
setIsAgentRunning(false);
|
||||
setAgentSessionId(null);
|
||||
setTaskSessionState(null);
|
||||
}
|
||||
} catch {
|
||||
// Failed to check task execution
|
||||
}
|
||||
};
|
||||
|
||||
checkExecution();
|
||||
const interval = setInterval(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
checkExecution();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [connectionStatus, task?.id]);
|
||||
// Agent is running if session state is STARTING or RUNNING
|
||||
const isAgentRunning = taskSessionState === 'STARTING' || taskSessionState === 'RUNNING';
|
||||
|
||||
const handleStartAgent = useCallback(async (agentProfileId: string, prompt?: string) => {
|
||||
if (!task?.id) return;
|
||||
@@ -114,31 +53,12 @@ export function useSessionAgent(task: Task | null): UseSessionAgentReturn {
|
||||
|
||||
setIsAgentLoading(true);
|
||||
try {
|
||||
interface StartResponse {
|
||||
success: boolean;
|
||||
task_id: string;
|
||||
agent_instance_id: string;
|
||||
session_id?: string;
|
||||
state: string;
|
||||
worktree_path?: string;
|
||||
worktree_branch?: string;
|
||||
}
|
||||
const response = await client.request<StartResponse>('orchestrator.start', {
|
||||
// Store will be updated via WebSocket notifications when session starts
|
||||
await client.request('orchestrator.start', {
|
||||
task_id: task.id,
|
||||
agent_profile_id: agentProfileId,
|
||||
prompt: prompt ?? task.description ?? '',
|
||||
}, 15000);
|
||||
setIsAgentRunning(true);
|
||||
setTaskSessionState(response.state as TaskSessionState);
|
||||
if (response?.session_id) {
|
||||
setAgentSessionId(response.session_id);
|
||||
}
|
||||
|
||||
// Update worktree info from response
|
||||
if (response?.worktree_path) {
|
||||
setWorktreePath(response.worktree_path);
|
||||
setWorktreeBranch(response.worktree_branch ?? null);
|
||||
}
|
||||
} catch {
|
||||
// Failed to start agent
|
||||
} finally {
|
||||
@@ -154,10 +74,8 @@ export function useSessionAgent(task: Task | null): UseSessionAgentReturn {
|
||||
|
||||
setIsAgentLoading(true);
|
||||
try {
|
||||
// Store will be updated via WebSocket notifications when session stops
|
||||
await client.request('orchestrator.stop', { task_id: task.id }, 15000);
|
||||
setIsAgentRunning(false);
|
||||
setAgentSessionId(null);
|
||||
setTaskSessionState(null);
|
||||
} catch {
|
||||
// Failed to stop agent
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import type { TaskSessionState } from '@/lib/types/http';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import type { TaskSessionState, TaskSession } from '@/lib/types/http';
|
||||
|
||||
const EMPTY_SESSIONS: TaskSession[] = [];
|
||||
|
||||
type UseTaskChatSessionReturn = {
|
||||
taskSessionId: string | null;
|
||||
@@ -10,73 +11,17 @@ type UseTaskChatSessionReturn = {
|
||||
};
|
||||
|
||||
export function useTaskChatSession(taskId: string | null): UseTaskChatSessionReturn {
|
||||
const store = useAppStoreApi();
|
||||
const [taskSessionIdsByTaskId, setTaskSessionIdsByTaskId] = useState<Record<string, string>>({});
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
const taskSessionId = useMemo(
|
||||
() => (taskId ? taskSessionIdsByTaskId[taskId] ?? null : null),
|
||||
[taskId, taskSessionIdsByTaskId]
|
||||
);
|
||||
const taskSessionState = useAppStore((state) =>
|
||||
taskSessionId ? state.taskSessions.items[taskSessionId]?.state ?? null : null
|
||||
const sessionsForTask = useAppStore((state) =>
|
||||
taskId ? state.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS : EMPTY_SESSIONS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) return;
|
||||
if (connectionStatus !== 'connected') return;
|
||||
|
||||
const checkExecution = async () => {
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const response = await client.request<{
|
||||
has_execution: boolean;
|
||||
task_id: string;
|
||||
state?: string;
|
||||
session_id?: string;
|
||||
}>('task.execution', { task_id: taskId });
|
||||
|
||||
setTaskSessionIdsByTaskId((prev) => {
|
||||
if (!taskId) return prev;
|
||||
if (!response.has_execution || !response.session_id) {
|
||||
if (!prev[taskId]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[taskId];
|
||||
return next;
|
||||
}
|
||||
if (prev[taskId] === response.session_id) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [taskId]: response.session_id };
|
||||
});
|
||||
|
||||
// Store partial session object in the store so useSession can access it
|
||||
if (response.has_execution && response.session_id && response.state) {
|
||||
store.getState().setTaskSession({
|
||||
id: response.session_id,
|
||||
task_id: taskId,
|
||||
state: response.state as TaskSessionState,
|
||||
progress: 0,
|
||||
started_at: '',
|
||||
updated_at: '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Failed to check task execution
|
||||
}
|
||||
};
|
||||
|
||||
checkExecution();
|
||||
const interval = setInterval(() => {
|
||||
if (connectionStatus === 'connected') {
|
||||
checkExecution();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [connectionStatus, taskId, store]);
|
||||
// Get the first (most recent) session for this task from the store
|
||||
const currentSession = useMemo(() => {
|
||||
return sessionsForTask[0] ?? null;
|
||||
}, [sessionsForTask]);
|
||||
|
||||
const taskSessionId = currentSession?.id ?? null;
|
||||
const taskSessionState = currentSession?.state ?? null;
|
||||
const isTaskSessionWorking = taskSessionState === 'STARTING' || taskSessionState === 'RUNNING';
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user