feat: replace agent_type with agent_profile_id (#14)

* feat: replace agent_type with agent_profile_id

- Replace agent_type field with agent_profile_id across backend and frontend
- Add ProfileResolver interface and StoreProfileResolver implementation
- Update task creation/update to use agent_profile_id
- Change default agent runtime from docker to standalone
- Fix workspace state types to include all required fields
- Fix websocket handlers type mismatches for environments, executors, workspaces
- Update integration tests to use agent_profile_id

* feat: pass model as CLI argument to agents

- Add ModelFlag field to AgentTypeConfig for specifying the CLI flag
- Set ModelFlag to '--model' for all agents (auggie, claude, codex, gemini, opencode, copilot)
- Update launchStandalone to append model flag to agent command
- Update launchDocker/buildContainerConfig to append model flag to container command
- Model is passed when both agent supports ModelFlag and profile has Model configured

* fix: make WebSocket message handling async to prevent blocking

When a long-running handler like orchestrator.prompt was executing,
it blocked the ReadPump loop, preventing other requests (like
workspace.tree.get) from being processed until the handler completed.

Now each message is handled in its own goroutine, allowing concurrent
message handling so file tree requests work while the agent is working.

* feat: show task description as first message in chat

- Add 'Task' label with distinct styling (lighter background, border)
- Pass taskDescription from page to TaskChatPanel component
- Differentiate from regular user messages visually
This commit is contained in:
João Salavisa
2026-01-14 12:29:58 +00:00
committed by GitHub
parent 42d4be97e5
commit 82e8c8905b
45 changed files with 791 additions and 461 deletions
+15 -14
View File
@@ -288,6 +288,7 @@ func main() {
// Lifecycle Manager (uses agentctl for agent communication)
lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, cfg.Agent, log)
lifecycleMgr.SetCredentialsManager(credsMgr)
lifecycleMgr.SetProfileResolver(lifecycle.NewStoreProfileResolver(agentSettingsRepo))
if err := lifecycleMgr.Start(ctx); err != nil {
log.Fatal("Failed to start lifecycle manager", zap.Error(err))
@@ -786,7 +787,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
TaskTitle: req.TaskTitle,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this
TaskDescription: req.TaskDescription,
Metadata: req.Metadata,
@@ -839,15 +840,15 @@ func (a *lifecycleAdapter) GetAgentStatus(ctx context.Context, agentInstanceID s
containerID := instance.ContainerID
now := time.Now()
result := &v1.AgentInstance{
ID: instance.ID,
TaskID: instance.TaskID,
AgentType: instance.AgentType,
ContainerID: &containerID,
Status: instance.Status,
StartedAt: &instance.StartedAt,
StoppedAt: instance.FinishedAt,
CreatedAt: instance.StartedAt,
UpdatedAt: now,
ID: instance.ID,
TaskID: instance.TaskID,
AgentProfileID: instance.AgentProfileID,
ContainerID: &containerID,
Status: instance.Status,
StartedAt: &instance.StartedAt,
StoppedAt: instance.FinishedAt,
CreatedAt: instance.StartedAt,
UpdatedAt: now,
}
if instance.ExitCode != nil {
@@ -893,10 +894,10 @@ func (a *lifecycleAdapter) GetRecoveredInstances() []executor.RecoveredInstanceI
result := make([]executor.RecoveredInstanceInfo, len(recovered))
for i, r := range recovered {
result[i] = executor.RecoveredInstanceInfo{
InstanceID: r.InstanceID,
TaskID: r.TaskID,
ContainerID: r.ContainerID,
AgentType: r.AgentType,
InstanceID: r.InstanceID,
TaskID: r.TaskID,
ContainerID: r.ContainerID,
AgentProfileID: r.AgentProfileID,
}
}
return result
@@ -37,16 +37,16 @@ func (c *Controller) ListAgents(ctx context.Context, req dto.ListAgentsRequest)
for _, a := range agents {
agent := dto.FromAgentInstance(&dto.AgentInstanceData{
ID: a.ID,
TaskID: a.TaskID,
AgentType: a.AgentType,
ContainerID: a.ContainerID,
Status: string(a.Status),
Progress: a.Progress,
StartedAt: a.StartedAt,
FinishedAt: a.FinishedAt,
ExitCode: a.ExitCode,
ErrorMessage: a.ErrorMessage,
ID: a.ID,
TaskID: a.TaskID,
AgentProfileID: a.AgentProfileID,
ContainerID: a.ContainerID,
Status: string(a.Status),
Progress: a.Progress,
StartedAt: a.StartedAt,
FinishedAt: a.FinishedAt,
ExitCode: a.ExitCode,
ErrorMessage: a.ErrorMessage,
})
resp.Agents = append(resp.Agents, agent)
}
@@ -61,10 +61,10 @@ func (c *Controller) LaunchAgent(ctx context.Context, req dto.LaunchAgentRequest
}
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
}
instance, err := c.lifecycle.Launch(ctx, launchReq)
@@ -91,16 +91,16 @@ func (c *Controller) GetAgentStatus(ctx context.Context, req dto.GetAgentStatusR
}
return dto.FromAgentInstance(&dto.AgentInstanceData{
ID: agent.ID,
TaskID: agent.TaskID,
AgentType: agent.AgentType,
ContainerID: agent.ContainerID,
Status: string(agent.Status),
Progress: agent.Progress,
StartedAt: agent.StartedAt,
FinishedAt: agent.FinishedAt,
ExitCode: agent.ExitCode,
ErrorMessage: agent.ErrorMessage,
ID: agent.ID,
TaskID: agent.TaskID,
AgentProfileID: agent.AgentProfileID,
ContainerID: agent.ContainerID,
Status: string(agent.Status),
Progress: agent.Progress,
StartedAt: agent.StartedAt,
FinishedAt: agent.FinishedAt,
ExitCode: agent.ExitCode,
ErrorMessage: agent.ErrorMessage,
}), nil
}
+32 -32
View File
@@ -8,17 +8,17 @@ type ListAgentsRequest struct{}
// AgentDTO represents an agent instance
type AgentDTO struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
AgentType string `json:"agent_type"`
ContainerID string `json:"container_id,omitempty"`
Status string `json:"status"`
Progress int `json:"progress"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
ID string `json:"id"`
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id"`
ContainerID string `json:"container_id,omitempty"`
Status string `json:"status"`
Progress int `json:"progress"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
Error string `json:"error,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ListAgentsResponse is the response for agent.list
@@ -29,10 +29,10 @@ type ListAgentsResponse struct {
// LaunchAgentRequest is the payload for agent.launch
type LaunchAgentRequest struct {
TaskID string `json:"task_id"`
AgentType string `json:"agent_type"`
WorkspacePath string `json:"workspace_path"`
Env map[string]string `json:"env,omitempty"`
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id"`
WorkspacePath string `json:"workspace_path"`
Env map[string]string `json:"env,omitempty"`
}
// LaunchAgentResponse is the response for agent.launch
@@ -91,13 +91,13 @@ type ListAgentTypesResponse struct {
// FromAgentInstance converts a lifecycle.AgentInstance to AgentDTO
func FromAgentInstance(a *AgentInstanceData) AgentDTO {
agent := AgentDTO{
ID: a.ID,
TaskID: a.TaskID,
AgentType: a.AgentType,
ContainerID: a.ContainerID,
Status: a.Status,
Progress: a.Progress,
StartedAt: a.StartedAt.Format("2006-01-02T15:04:05Z"),
ID: a.ID,
TaskID: a.TaskID,
AgentProfileID: a.AgentProfileID,
ContainerID: a.ContainerID,
Status: a.Status,
Progress: a.Progress,
StartedAt: a.StartedAt.Format("2006-01-02T15:04:05Z"),
}
if a.FinishedAt != nil && !a.FinishedAt.IsZero() {
agent.FinishedAt = a.FinishedAt.Format("2006-01-02T15:04:05Z")
@@ -113,16 +113,16 @@ func FromAgentInstance(a *AgentInstanceData) AgentDTO {
// AgentInstanceData is the input data for FromAgentInstance (avoids circular import)
type AgentInstanceData struct {
ID string
TaskID string
AgentType string
ContainerID string
Status string
Progress int
StartedAt time.Time
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
ID string
TaskID string
AgentProfileID string
ContainerID string
Status string
Progress int
StartedAt time.Time
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
}
// FromAgentType converts a registry.AgentType to AgentTypeDTO
@@ -47,10 +47,10 @@ func (h *Handlers) wsListAgents(ctx context.Context, msg *ws.Message) (*ws.Messa
}
type wsLaunchAgentRequest struct {
TaskID string `json:"task_id"`
AgentType string `json:"agent_type"`
WorkspacePath string `json:"workspace_path"`
Env map[string]string `json:"env,omitempty"`
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id"`
WorkspacePath string `json:"workspace_path"`
Env map[string]string `json:"env,omitempty"`
}
func (h *Handlers) wsLaunchAgent(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -61,23 +61,23 @@ func (h *Handlers) wsLaunchAgent(ctx context.Context, msg *ws.Message) (*ws.Mess
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.AgentType == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_type is required", nil)
if req.AgentProfileID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required", nil)
}
if req.WorkspacePath == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_path is required", nil)
}
resp, err := h.controller.LaunchAgent(ctx, dto.LaunchAgentRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
})
if err != nil {
h.logger.Error("failed to launch agent",
zap.String("task_id", req.TaskID),
zap.String("agent_type", req.AgentType),
zap.String("agent_profile_id", req.AgentProfileID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch agent: "+err.Error(), nil)
}
+147 -72
View File
@@ -31,10 +31,10 @@ const AgentCtlPort = 9999
// AgentInstance represents a running agent instance
type AgentInstance struct {
ID string
TaskID string
AgentType string
ContainerID string
ID string
TaskID string
AgentProfileID string
ContainerID string
ContainerIP string // IP address of the container for agentctl communication
WorkspacePath string // Path to the workspace (worktree or repository path)
Status v1.AgentStatus
@@ -66,7 +66,7 @@ func (ai *AgentInstance) GetAgentCtlClient() *agentctl.Client {
type LaunchRequest struct {
TaskID string
TaskTitle string // Human-readable task title for semantic worktree naming
AgentType string
AgentProfileID string
WorkspacePath string // Host path to workspace (original repository path)
TaskDescription string // Task description to send via ACP prompt
Env map[string]string // Additional env vars
@@ -84,14 +84,32 @@ type CredentialsManager interface {
GetCredentialValue(ctx context.Context, key string) (value string, err error)
}
// AgentProfileInfo contains resolved profile information
type AgentProfileInfo struct {
ProfileID string
ProfileName string
AgentID string
AgentName string // e.g., "auggie", "claude", "codex"
Model string
AutoApprove bool
DangerouslySkipPermissions bool
Plan string
}
// ProfileResolver resolves agent profile IDs to profile information
type ProfileResolver interface {
ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
}
// Manager manages agent instance lifecycles
type Manager struct {
docker *docker.Client
registry *registry.Registry
eventBus bus.EventBus
credsMgr CredentialsManager
worktreeMgr *worktree.Manager
logger *logger.Logger
docker *docker.Client
registry *registry.Registry
eventBus bus.EventBus
credsMgr CredentialsManager
profileResolver ProfileResolver
worktreeMgr *worktree.Manager
logger *logger.Logger
// Agent runtime configuration
agentCfg config.AgentConfig
@@ -155,6 +173,11 @@ func (m *Manager) SetCredentialsManager(credsMgr CredentialsManager) {
m.credsMgr = credsMgr
}
// SetProfileResolver sets the profile resolver for looking up agent profiles
func (m *Manager) SetProfileResolver(resolver ProfileResolver) {
m.profileResolver = resolver
}
// SetWorktreeManager sets the worktree manager for Git worktree isolation
func (m *Manager) SetWorktreeManager(worktreeMgr *worktree.Manager) {
m.worktreeMgr = worktreeMgr
@@ -193,10 +216,10 @@ func (m *Manager) Start(ctx context.Context) error {
// RecoveredInstance contains info about an instance recovered from Docker
type RecoveredInstance struct {
InstanceID string
TaskID string
ContainerID string
AgentType string
InstanceID string
TaskID string
ContainerID string
AgentProfileID string
}
// reconcileWithDocker finds running kandev containers and re-populates tracking maps
@@ -242,7 +265,7 @@ func (m *Manager) reconcileWithDocker(ctx context.Context) ([]RecoveredInstance,
instanceID := labels["kandev.instance_id"]
taskID := labels["kandev.task_id"]
agentType := labels["kandev.agent_type"]
agentProfileID := labels["kandev.agent_profile_id"]
if instanceID == "" || taskID == "" {
m.logger.Warn("container missing required labels",
@@ -261,10 +284,10 @@ func (m *Manager) reconcileWithDocker(ctx context.Context) ([]RecoveredInstance,
// Create instance record
instance := &AgentInstance{
ID: instanceID,
TaskID: taskID,
AgentType: agentType,
ContainerID: ctr.ID,
ID: instanceID,
TaskID: taskID,
AgentProfileID: agentProfileID,
ContainerID: ctr.ID,
ContainerIP: containerIP,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(), // We don't know exact start time
@@ -279,10 +302,10 @@ func (m *Manager) reconcileWithDocker(ctx context.Context) ([]RecoveredInstance,
m.byContainer[ctr.ID] = instanceID
recovered = append(recovered, RecoveredInstance{
InstanceID: instanceID,
TaskID: taskID,
ContainerID: ctr.ID,
AgentType: agentType,
InstanceID: instanceID,
TaskID: taskID,
ContainerID: ctr.ID,
AgentProfileID: agentProfileID,
})
// Reconnect to WebSocket streams for updates and permissions
@@ -311,10 +334,10 @@ func (m *Manager) GetRecoveredInstances() []RecoveredInstance {
result := make([]RecoveredInstance, 0, len(m.instances))
for _, inst := range m.instances {
result = append(result, RecoveredInstance{
InstanceID: inst.ID,
TaskID: inst.TaskID,
ContainerID: inst.ContainerID,
AgentType: inst.AgentType,
InstanceID: inst.ID,
TaskID: inst.TaskID,
ContainerID: inst.ContainerID,
AgentProfileID: inst.AgentProfileID,
})
}
return result
@@ -334,20 +357,42 @@ func (m *Manager) Stop() error {
func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstance, error) {
m.logger.Info("launching agent",
zap.String("task_id", req.TaskID),
zap.String("agent_type", req.AgentType),
zap.String("agent_profile_id", req.AgentProfileID),
zap.Bool("use_worktree", req.UseWorktree))
// 1. Validate the agent type exists in registry
agentConfig, err := m.registry.Get(req.AgentType)
// 1. Resolve the agent profile to get agent type info
var agentTypeName string
var profileInfo *AgentProfileInfo
var err error
if m.profileResolver != nil {
profileInfo, err = m.profileResolver.ResolveProfile(ctx, req.AgentProfileID)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent profile: %w", err)
}
// Map agent name to registry ID (e.g., "auggie" -> "auggie-agent")
agentTypeName = profileInfo.AgentName + "-agent"
m.logger.Info("resolved agent profile",
zap.String("profile_id", req.AgentProfileID),
zap.String("agent_name", profileInfo.AgentName),
zap.String("agent_type", agentTypeName))
} else {
// Fallback: treat AgentProfileID as agent type directly (for backward compat)
agentTypeName = req.AgentProfileID
m.logger.Warn("no profile resolver configured, using profile ID as agent type",
zap.String("agent_type", agentTypeName))
}
// 2. Get agent config from registry
agentConfig, err := m.registry.Get(agentTypeName)
if err != nil {
return nil, fmt.Errorf("agent type not found: %w", err)
return nil, fmt.Errorf("agent type %q not found in registry: %w", agentTypeName, err)
}
if !agentConfig.Enabled {
return nil, fmt.Errorf("agent type %q is disabled", req.AgentType)
return nil, fmt.Errorf("agent type %q is disabled", agentTypeName)
}
// 2. Check if task already has an agent running
// 3. Check if task already has an agent running
m.mu.RLock()
if existingID, exists := m.byTask[req.TaskID]; exists {
m.mu.RUnlock()
@@ -355,7 +400,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
}
m.mu.RUnlock()
// 3. Handle worktree creation/reuse if enabled
// 4. Handle worktree creation/reuse if enabled
workspacePath := req.WorkspacePath
var mainRepoGitDir string // Path to main repo's .git directory for container mount
var worktreeID string // Store worktree ID for session resumption
@@ -393,25 +438,44 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
zap.String("workspace_path", workspacePath))
}
// 4. Generate a new instance ID
// 5. Generate a new instance ID
instanceID := uuid.New().String()
// 5. Prepare request with worktree path
// 6. Prepare request with worktree path
reqWithWorktree := *req
reqWithWorktree.WorkspacePath = workspacePath
// 6. Launch via appropriate runtime (Docker or Standalone)
// 6b. Add profile settings to environment
if profileInfo != nil {
if reqWithWorktree.Env == nil {
reqWithWorktree.Env = make(map[string]string)
}
if profileInfo.Model != "" {
reqWithWorktree.Env["AGENT_MODEL"] = profileInfo.Model
}
if profileInfo.AutoApprove {
reqWithWorktree.Env["AGENT_AUTO_APPROVE"] = "true"
}
if profileInfo.DangerouslySkipPermissions {
reqWithWorktree.Env["AGENT_DANGEROUSLY_SKIP_PERMISSIONS"] = "true"
}
if profileInfo.Plan != "" {
reqWithWorktree.Env["AGENT_PLAN"] = profileInfo.Plan
}
}
// 7. Launch via appropriate runtime (Docker or Standalone)
var instance *AgentInstance
if m.IsStandaloneMode() {
instance, err = m.launchStandalone(ctx, instanceID, &reqWithWorktree, agentConfig, worktreeID, worktreeBranch)
instance, err = m.launchStandalone(ctx, instanceID, &reqWithWorktree, agentConfig, profileInfo, worktreeID, worktreeBranch)
} else {
instance, err = m.launchDocker(ctx, instanceID, &reqWithWorktree, agentConfig, mainRepoGitDir, worktreeID, worktreeBranch)
instance, err = m.launchDocker(ctx, instanceID, &reqWithWorktree, agentConfig, profileInfo, mainRepoGitDir, worktreeID, worktreeBranch)
}
if err != nil {
return nil, err
}
// 7. Track the instance
// 8. Track the instance
m.mu.Lock()
m.instances[instanceID] = instance
m.byTask[req.TaskID] = instanceID
@@ -420,10 +484,10 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
}
m.mu.Unlock()
// 8. Publish agent.started event
// 9. Publish agent.started event
m.publishEvent(ctx, events.AgentStarted, instance)
// 9. Wait for agentctl to be ready and start the agent process
// 10. Wait for agentctl to be ready and start the agent process
go m.initializeAgent(instance, req.TaskDescription)
m.logger.Info("agent launched successfully",
@@ -435,9 +499,9 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
}
// launchDocker launches an agent in a Docker container
func (m *Manager) launchDocker(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, mainRepoGitDir, worktreeID, worktreeBranch string) (*AgentInstance, error) {
func (m *Manager) launchDocker(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, mainRepoGitDir, worktreeID, worktreeBranch string) (*AgentInstance, error) {
// Build container config from registry config
containerConfig := m.buildContainerConfig(instanceID, req, agentConfig, mainRepoGitDir)
containerConfig := m.buildContainerConfig(instanceID, req, agentConfig, profileInfo, mainRepoGitDir)
// Create and start the container
containerID, err := m.docker.CreateContainer(ctx, containerConfig)
@@ -479,22 +543,22 @@ func (m *Manager) launchDocker(ctx context.Context, instanceID string, req *Laun
zap.String("container_ip", containerIP))
return &AgentInstance{
ID: instanceID,
TaskID: req.TaskID,
AgentType: req.AgentType,
ContainerID: containerID,
ContainerIP: containerIP,
WorkspacePath: "/workspace", // Docker mounts workspace to /workspace
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 0,
Metadata: instanceMetadata,
agentctl: agentctlClient,
ID: instanceID,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
ContainerID: containerID,
ContainerIP: containerIP,
WorkspacePath: "/workspace", // Docker mounts workspace to /workspace
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 0,
Metadata: instanceMetadata,
agentctl: agentctlClient,
}, nil
}
// launchStandalone launches an agent via standalone agentctl
func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, worktreeID, worktreeBranch string) (*AgentInstance, error) {
func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, worktreeID, worktreeBranch string) (*AgentInstance, error) {
// Build environment variables for the agent
env := m.buildStandaloneEnv(instanceID, req, agentConfig)
@@ -504,6 +568,10 @@ func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *
if len(agentConfig.Cmd) > 0 {
agentCommand = strings.Join(agentConfig.Cmd, " ")
}
// Append model flag if agent supports it and profile has a model configured
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
agentCommand = agentCommand + " " + agentConfig.ModelFlag + " " + profileInfo.Model
}
// If empty, agentctl will use its default (auggie --acp)
// Create instance via control API
@@ -544,7 +612,7 @@ func (m *Manager) launchStandalone(ctx context.Context, instanceID string, req *
return &AgentInstance{
ID: instanceID,
TaskID: req.TaskID,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
WorkspacePath: req.WorkspacePath,
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
@@ -1421,7 +1489,7 @@ func (m *Manager) updateInstanceError(instanceID, errorMsg string) {
// buildContainerConfig builds a Docker container config from agent registry config
// mainRepoGitDir is the host path to the main repository's .git directory (e.g., /path/to/repo/.git)
// If non-empty, it will be mounted into the container at the same path so git worktree commands work correctly
func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, mainRepoGitDir string) docker.ContainerConfig {
func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo, mainRepoGitDir string) docker.ContainerConfig {
// Build image name with tag
imageName := agentConfig.Image
if agentConfig.Tag != "" {
@@ -1511,20 +1579,27 @@ func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, ag
containerName := fmt.Sprintf("kandev-agent-%s", instanceID[:8])
// Build command, appending model flag if agent supports it and profile has a model
cmd := make([]string, len(agentConfig.Cmd))
copy(cmd, agentConfig.Cmd)
if agentConfig.ModelFlag != "" && profileInfo != nil && profileInfo.Model != "" {
cmd = append(cmd, agentConfig.ModelFlag, profileInfo.Model)
}
return docker.ContainerConfig{
Name: containerName,
Image: imageName,
Cmd: agentConfig.Cmd,
Cmd: cmd,
Env: env,
WorkingDir: agentConfig.WorkingDir,
Mounts: mounts,
Memory: memoryBytes,
CPUQuota: cpuQuota,
Labels: map[string]string{
"kandev.managed": "true",
"kandev.instance_id": instanceID,
"kandev.task_id": req.TaskID,
"kandev.agent_type": req.AgentType,
"kandev.managed": "true",
"kandev.instance_id": instanceID,
"kandev.task_id": req.TaskID,
"kandev.agent_profile_id": req.AgentProfileID,
},
AutoRemove: false, // We manage cleanup ourselves
}
@@ -1921,14 +1996,14 @@ func (m *Manager) publishEvent(ctx context.Context, eventType string, instance *
}
data := map[string]interface{}{
"instance_id": instance.ID,
"task_id": instance.TaskID,
"agent_type": instance.AgentType,
"container_id": instance.ContainerID,
"status": string(instance.Status),
"started_at": instance.StartedAt,
"progress": instance.Progress,
"error_message": instance.ErrorMessage,
"instance_id": instance.ID,
"task_id": instance.TaskID,
"agent_profile_id": instance.AgentProfileID,
"container_id": instance.ContainerID,
"status": string(instance.Status),
"started_at": instance.StartedAt,
"progress": instance.Progress,
"error_message": instance.ErrorMessage,
}
if instance.FinishedAt != nil {
@@ -178,12 +178,12 @@ func TestManager_GetInstance(t *testing.T) {
// Manually add an instance for testing
instance := &AgentInstance{
ID: "test-instance-id",
TaskID: "test-task-id",
AgentType: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
ID: "test-instance-id",
TaskID: "test-task-id",
AgentProfileID: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
}
mgr.mu.Lock()
@@ -215,12 +215,12 @@ func TestManager_GetInstanceByTaskID(t *testing.T) {
mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log)
instance := &AgentInstance{
ID: "test-instance-id",
TaskID: "test-task-id",
AgentType: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
ID: "test-instance-id",
TaskID: "test-task-id",
AgentProfileID: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
}
mgr.mu.Lock()
@@ -251,12 +251,12 @@ func TestManager_GetInstanceByContainerID(t *testing.T) {
mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log)
instance := &AgentInstance{
ID: "test-instance-id",
TaskID: "test-task-id",
AgentType: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
ID: "test-instance-id",
TaskID: "test-task-id",
AgentProfileID: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
}
mgr.mu.Lock()
@@ -380,13 +380,13 @@ func TestManager_MarkCompleted_Success(t *testing.T) {
mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log)
instance := &AgentInstance{
ID: "test-instance-id",
TaskID: "test-task-id",
AgentType: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 50,
ID: "test-instance-id",
TaskID: "test-task-id",
AgentProfileID: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
Progress: 50,
}
mgr.mu.Lock()
@@ -421,12 +421,12 @@ func TestManager_MarkCompleted_Failure(t *testing.T) {
mgr := NewManager(nil, reg, eventBus, testAgentConfig(), log)
instance := &AgentInstance{
ID: "test-instance-id",
TaskID: "test-task-id",
AgentType: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
ID: "test-instance-id",
TaskID: "test-task-id",
AgentProfileID: "test-agent",
ContainerID: "container-123",
Status: v1.AgentStatusRunning,
StartedAt: time.Now(),
}
mgr.mu.Lock()
@@ -0,0 +1,45 @@
package lifecycle
import (
"context"
"fmt"
"github.com/kandev/kandev/internal/agent/settings/store"
)
// StoreProfileResolver implements ProfileResolver using the agent settings store
type StoreProfileResolver struct {
store store.Repository
}
// NewStoreProfileResolver creates a new profile resolver using the given store
func NewStoreProfileResolver(store store.Repository) *StoreProfileResolver {
return &StoreProfileResolver{store: store}
}
// ResolveProfile looks up an agent profile by ID and returns the profile info
func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
// Get the profile from the store
profile, err := r.store.GetAgentProfile(ctx, profileID)
if err != nil {
return nil, fmt.Errorf("profile not found: %w", err)
}
// Get the parent agent to get the agent name
agent, err := r.store.GetAgent(ctx, profile.AgentID)
if err != nil {
return nil, fmt.Errorf("agent not found for profile: %w", err)
}
return &AgentProfileInfo{
ProfileID: profile.ID,
ProfileName: profile.Name,
AgentID: agent.ID,
AgentName: agent.Name,
Model: profile.Model,
AutoApprove: profile.AutoApprove,
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
Plan: profile.Plan,
}, nil
}
@@ -4,16 +4,15 @@ package registry
func DefaultAgents() []*AgentTypeConfig {
return []*AgentTypeConfig{
{
ID: "augment-agent",
ID: "auggie-agent",
Name: "Augment Coding Agent",
Description: "Auggie CLI-powered autonomous coding agent. Requires AUGMENT_SESSION_AUTH for authentication.",
Image: "kandev/augment-agent",
Tag: "latest",
Cmd: []string{"auggie", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{"AUGMENT_SESSION_AUTH"},
Env: map[string]string{
// Auto-approve permissions for now (until full permission flow is implemented)
// This allows the agent to proceed without waiting for user approval
"AGENTCTL_AUTO_APPROVE_PERMISSIONS": "true",
},
Mounts: []MountTemplate{
@@ -23,11 +22,121 @@ func DefaultAgents() []*AgentTypeConfig {
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600, // 1 hour
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
{
ID: "claude-agent",
Name: "Claude Code Agent",
Description: "Claude Code CLI-powered autonomous coding agent.",
Image: "kandev/claude-agent",
Tag: "latest",
Cmd: []string{"claude", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{},
Env: map[string]string{},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
{
ID: "codex-agent",
Name: "OpenAI Codex Agent",
Description: "OpenAI Codex CLI-powered autonomous coding agent.",
Image: "kandev/codex-agent",
Tag: "latest",
Cmd: []string{"codex", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{},
Env: map[string]string{},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
{
ID: "gemini-agent",
Name: "Gemini CLI Agent",
Description: "Google Gemini CLI-powered autonomous coding agent.",
Image: "kandev/gemini-agent",
Tag: "latest",
Cmd: []string{"gemini", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{},
Env: map[string]string{},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
{
ID: "opencode-agent",
Name: "OpenCode Agent",
Description: "OpenCode CLI-powered autonomous coding agent.",
Image: "kandev/opencode-agent",
Tag: "latest",
Cmd: []string{"opencode", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{},
Env: map[string]string{},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
{
ID: "copilot-agent",
Name: "GitHub Copilot Agent",
Description: "GitHub Copilot CLI-powered autonomous coding agent.",
Image: "kandev/copilot-agent",
Tag: "latest",
Cmd: []string{"copilot", "--acp"},
WorkingDir: "/workspace",
RequiredEnv: []string{},
Env: map[string]string{},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
},
ResourceLimits: ResourceLimits{
MemoryMB: 4096,
CPUCores: 2.0,
TimeoutSeconds: 3600,
},
Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"},
Enabled: true,
ModelFlag: "--model",
},
}
}
@@ -30,6 +30,7 @@ type AgentTypeConfig struct {
ResourceLimits ResourceLimits `json:"resource_limits"`
Capabilities []string `json:"capabilities"` // What the agent can do
Enabled bool `json:"enabled"`
ModelFlag string `json:"model_flag,omitempty"` // CLI flag for model selection (e.g., "--model")
}
// MountTemplate defines a mount with template support
@@ -91,8 +91,8 @@ type WorktreeConfig struct {
// AgentConfig holds agent runtime configuration.
type AgentConfig struct {
// Runtime specifies the agent runtime mode: "docker" or "standalone"
// - "docker": Agents run in Docker containers (default)
// - "standalone": Agents run via standalone agentctl on the host machine
// - "standalone": Agents run via standalone agentctl on the host machine (default)
// - "docker": Agents run in Docker containers
Runtime string `mapstructure:"runtime"`
// StandaloneHost is the host where standalone agentctl is running (default: localhost)
@@ -149,7 +149,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("docker.volumeBasePath", "/var/lib/kandev/volumes")
// Agent defaults
v.SetDefault("agent.runtime", "docker")
v.SetDefault("agent.runtime", "standalone")
v.SetDefault("agent.standaloneHost", "localhost")
v.SetDefault("agent.standalonePort", 9999)
@@ -83,8 +83,10 @@ func (c *Client) ReadPump(ctx context.Context) {
continue
}
// Process the message
c.handleMessage(ctx, &msg)
// Process the message in a goroutine to avoid blocking the read pump
// This allows concurrent message handling so long-running handlers
// (like orchestrator.prompt) don't block other requests (like workspace.tree.get)
go c.handleMessage(ctx, &msg)
}
}
@@ -60,11 +60,11 @@ type SimulatedAgentManagerClient struct {
// simulatedInstance tracks a simulated agent instance
type simulatedInstance struct {
id string
taskID string
agentType string
status v1.AgentStatus
stopCh chan struct{}
id string
taskID string
agentProfileID string
status v1.AgentStatus
stopCh chan struct{}
}
// NewSimulatedAgentManager creates a new simulated agent manager
@@ -121,11 +121,11 @@ func (s *SimulatedAgentManagerClient) LaunchAgent(ctx context.Context, req *exec
s.mu.Lock()
instance := &simulatedInstance{
id: instanceID,
taskID: req.TaskID,
agentType: req.AgentType,
status: v1.AgentStatusStarting,
stopCh: make(chan struct{}),
id: instanceID,
taskID: req.TaskID,
agentProfileID: req.AgentProfileID,
status: v1.AgentStatusStarting,
stopCh: make(chan struct{}),
}
s.instances[instanceID] = instance
s.mu.Unlock()
@@ -175,13 +175,13 @@ func (s *SimulatedAgentManagerClient) runAgentSimulation(instance *simulatedInst
// publishAgentEvent publishes an agent lifecycle event
func (s *SimulatedAgentManagerClient) publishAgentEvent(eventType string, instance *simulatedInstance) {
data := map[string]interface{}{
"instance_id": instance.id,
"task_id": instance.taskID,
"agent_type": instance.agentType,
"container_id": "sim-container-" + instance.id[:8],
"status": string(instance.status),
"started_at": time.Now(),
"progress": 50,
"instance_id": instance.id,
"task_id": instance.taskID,
"agent_profile_id": instance.agentProfileID,
"container_id": "sim-container-" + instance.id[:8],
"status": string(instance.status),
"started_at": time.Now(),
"progress": 50,
}
event := bus.NewEvent(eventType, "simulated-agent-manager", data)
@@ -278,10 +278,10 @@ func (s *SimulatedAgentManagerClient) GetAgentStatus(ctx context.Context, agentI
}
return &v1.AgentInstance{
ID: instance.id,
TaskID: instance.taskID,
AgentType: instance.agentType,
Status: instance.status,
ID: instance.id,
TaskID: instance.taskID,
AgentProfileID: instance.agentProfileID,
Status: instance.status,
}, nil
}
@@ -568,8 +568,8 @@ func (ts *OrchestratorTestServer) Close() {
ts.EventBus.Close()
}
// CreateTestTask creates a task with agent_type set for testing
func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentType string, priority int) string {
// CreateTestTask creates a task with agent_profile_id set for testing
func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string {
t.Helper()
workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{
@@ -594,16 +594,16 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentType string,
})
require.NoError(t, err)
// Create task with agent type
// Create task with agent profile ID
task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{
WorkspaceID: workspace.ID,
BoardID: board.ID,
ColumnID: col.ID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: priority,
AgentType: agentType,
RepositoryURL: "/tmp/repo",
WorkspaceID: workspace.ID,
BoardID: board.ID,
ColumnID: col.ID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: priority,
AgentProfileID: agentProfileID,
RepositoryURL: "/tmp/repo",
})
require.NoError(t, err)
@@ -805,8 +805,8 @@ func TestOrchestratorStartTask(t *testing.T) {
ts := NewOrchestratorTestServer(t)
defer ts.Close()
// Create a task with agent_type
taskID := ts.CreateTestTask(t, "augment-agent", 2)
// Create a task with agent_profile_id
taskID := ts.CreateTestTask(t, "test-profile-id", 2)
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
@@ -856,11 +856,11 @@ func TestOrchestratorStartTaskWithAgentTypeOverride(t *testing.T) {
client := NewOrchestratorWSClient(t, ts.Server.URL)
defer client.Close()
// Start with different agent type
// Start with different agent profile
resp, err := client.SendRequest("start-1", ws.ActionOrchestratorStart, map[string]interface{}{
"task_id": taskID,
"agent_type": "auggie-cli",
"priority": 3,
"task_id": taskID,
"agent_profile_id": "override-profile-id",
"priority": 3,
})
require.NoError(t, err)
@@ -1347,16 +1347,16 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
require.NoError(t, colResp.ParsePayload(&colPayload))
colID := colPayload["id"].(string)
// 3. Create task with agent type
// 3. Create task with agent_profile_id
taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": colID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"agent_type": "augment-agent",
"repository_url": "/tmp/repo",
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": colID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"agent_profile_id": "test-profile-id",
"repository_url": "/tmp/repo",
})
require.NoError(t, err)
@@ -62,7 +62,7 @@ func (c *Controller) TriggerTask(ctx context.Context, req dto.TriggerTaskRequest
// StartTask starts task execution
func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (dto.StartTaskResponse, error) {
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentType, req.Priority)
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.Priority)
if err != nil {
return dto.StartTaskResponse{}, err
}
@@ -151,7 +151,7 @@ func (c *Controller) GetTaskExecution(ctx context.Context, req dto.GetTaskExecut
HasExecution: true,
TaskID: execution.TaskID,
AgentInstanceID: execution.AgentInstanceID,
AgentType: execution.AgentType,
AgentProfileID: execution.AgentProfileID,
Status: string(execution.Status),
Progress: execution.Progress,
StartedAt: execution.StartedAt.Format("2006-01-02T15:04:05Z"),
@@ -44,9 +44,9 @@ type TriggerTaskResponse struct {
// StartTaskRequest is the payload for orchestrator.start
type StartTaskRequest struct {
TaskID string `json:"task_id"`
AgentType string `json:"agent_type,omitempty"`
Priority int `json:"priority,omitempty"`
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
Priority int `json:"priority,omitempty"`
}
// StartTaskResponse is the response for orchestrator.start
@@ -128,7 +128,7 @@ type TaskExecutionResponse struct {
HasExecution bool `json:"has_execution"`
TaskID string `json:"task_id"`
AgentInstanceID string `json:"agent_instance_id,omitempty"`
AgentType string `json:"agent_type,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
Status string `json:"status,omitempty"`
Progress int `json:"progress,omitempty"`
StartedAt string `json:"started_at,omitempty"`
@@ -18,9 +18,9 @@ import (
// Common errors
var (
ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached")
ErrNoAgentType = errors.New("task has no agent_type configured")
ErrExecutionNotFound = errors.New("execution not found")
ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached")
ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured")
ErrExecutionNotFound = errors.New("execution not found")
)
// PromptResult contains the result of a prompt operation
@@ -63,7 +63,7 @@ type AgentManagerClient interface {
type LaunchAgentRequest struct {
TaskID string
TaskTitle string // Human-readable task title for semantic worktree naming
AgentType string
AgentProfileID string
RepositoryURL string
Branch string
TaskDescription string // Task description to send via ACP prompt
@@ -90,7 +90,7 @@ type LaunchAgentResponse struct {
type TaskExecution struct {
TaskID string
AgentInstanceID string
AgentType string
AgentProfileID string
StartedAt time.Time
Status v1.AgentStatus
Progress int
@@ -107,7 +107,7 @@ func FromAgentSession(s *models.AgentSession) *TaskExecution {
return &TaskExecution{
TaskID: s.TaskID,
AgentInstanceID: s.AgentInstanceID,
AgentType: s.AgentType,
AgentProfileID: s.AgentType, // AgentSession still uses AgentType field in DB
StartedAt: s.StartedAt,
Status: agentSessionStatusToV1(s.Status),
Progress: s.Progress,
@@ -227,10 +227,10 @@ func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error {
// RecoveredInstanceInfo contains info about an instance recovered from Docker
type RecoveredInstanceInfo struct {
InstanceID string
TaskID string
ContainerID string
AgentType string
InstanceID string
TaskID string
ContainerID string
AgentProfileID string
}
// SyncWithRecoveredInstances ensures the executor's cache is in sync with
@@ -249,7 +249,7 @@ func (e *Executor) SyncWithRecoveredInstances(ctx context.Context, instances []R
e.executions[inst.TaskID] = &TaskExecution{
TaskID: inst.TaskID,
AgentInstanceID: inst.InstanceID,
AgentType: inst.AgentType,
AgentProfileID: inst.AgentProfileID,
StartedAt: time.Now(), // We don't know exact start time
Status: v1.AgentStatusRunning,
Progress: 0,
@@ -272,17 +272,17 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution,
return nil, ErrMaxConcurrentReached
}
// Verify task has an agent_type configured
if task.AgentType == nil || *task.AgentType == "" {
e.logger.Error("task has no agent_type configured", zap.String("task_id", task.ID))
return nil, ErrNoAgentType
// Verify task has an agent_profile_id configured
if task.AgentProfileID == nil || *task.AgentProfileID == "" {
e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID))
return nil, ErrNoAgentProfileID
}
// Create a LaunchAgentRequest from the task
req := &LaunchAgentRequest{
TaskID: task.ID,
TaskTitle: task.Title,
AgentType: *task.AgentType,
AgentProfileID: *task.AgentProfileID,
TaskDescription: task.Description,
Priority: task.Priority,
Metadata: task.Metadata,
@@ -308,7 +308,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution,
e.logger.Info("launching agent for task",
zap.String("task_id", task.ID),
zap.String("agent_type", *task.AgentType),
zap.String("agent_profile_id", *task.AgentProfileID),
zap.Bool("use_worktree", req.UseWorktree))
// Call the AgentManager to launch the container
@@ -328,7 +328,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution,
TaskID: task.ID,
AgentInstanceID: resp.AgentInstanceID,
ContainerID: resp.ContainerID,
AgentType: *task.AgentType,
AgentType: *task.AgentProfileID, // AgentSession still uses AgentType field in DB
Status: v1StatusToAgentSessionStatus(resp.Status),
Progress: 0,
StartedAt: now,
@@ -346,7 +346,7 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution,
execution := &TaskExecution{
TaskID: task.ID,
AgentInstanceID: resp.AgentInstanceID,
AgentType: *task.AgentType,
AgentProfileID: *task.AgentProfileID,
StartedAt: now,
Status: resp.Status,
Progress: 0,
@@ -727,7 +727,7 @@ func NewMockAgentManagerClient(log *logger.Logger) *MockAgentManagerClient {
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_type", req.AgentType),
zap.String("agent_profile_id", req.AgentProfileID),
zap.String("repository_url", req.RepositoryURL),
zap.String("branch", req.Branch))
@@ -752,9 +752,9 @@ func (m *MockAgentManagerClient) GetAgentStatus(ctx context.Context, agentInstan
zap.String("agent_instance_id", agentInstanceID))
return &v1.AgentInstance{
ID: agentInstanceID,
Status: v1.AgentStatusRunning,
AgentType: "mock-agent",
ID: agentInstanceID,
Status: v1.AgentStatusRunning,
AgentProfileID: "mock-agent",
}, nil
}
@@ -80,9 +80,9 @@ func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Mess
}
type wsStartTaskRequest struct {
TaskID string `json:"task_id"`
AgentType string `json:"agent_type,omitempty"`
Priority int `json:"priority,omitempty"`
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
Priority int `json:"priority,omitempty"`
}
func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -95,9 +95,9 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
}
resp, err := h.controller.StartTask(ctx, dto.StartTaskRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
Priority: req.Priority,
TaskID: req.TaskID,
AgentProfileID: req.AgentProfileID,
Priority: req.Priority,
})
if err != nil {
h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err))
@@ -18,12 +18,12 @@ var (
// QueuedTask represents a task in the priority queue
type QueuedTask struct {
TaskID string
Priority int // Higher priority = processed first
AgentType string
QueuedAt time.Time
Task *v1.Task // Full task data
index int // Index in the heap (used by container/heap)
TaskID string
Priority int // Higher priority = processed first
AgentProfileID string
QueuedAt time.Time
Task *v1.Task // Full task data
index int // Index in the heap (used by container/heap)
}
// taskHeap implements heap.Interface for priority queue
@@ -95,17 +95,17 @@ func (q *TaskQueue) Enqueue(task *v1.Task) error {
return ErrQueueFull
}
agentType := ""
if task.AgentType != nil {
agentType = *task.AgentType
agentProfileID := ""
if task.AgentProfileID != nil {
agentProfileID = *task.AgentProfileID
}
qt := &QueuedTask{
TaskID: task.ID,
Priority: task.Priority,
AgentType: agentType,
QueuedAt: time.Now(),
Task: task,
TaskID: task.ID,
Priority: task.Priority,
AgentProfileID: agentProfileID,
QueuedAt: time.Now(),
Task: task,
}
heap.Push(&q.heap, qt)
@@ -8,16 +8,16 @@ import (
)
// createTestTask creates a task for testing with the given parameters
func createTestTask(id string, priority int, agentType string) *v1.Task {
func createTestTask(id string, priority int, agentProfileID string) *v1.Task {
return &v1.Task{
ID: id,
BoardID: "test-board",
Title: "Test Task " + id,
Priority: priority,
AgentType: &agentType,
State: v1.TaskStateTODO,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ID: id,
BoardID: "test-board",
Title: "Test Task " + id,
Priority: priority,
AgentProfileID: &agentProfileID,
State: v1.TaskStateTODO,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
@@ -130,16 +130,16 @@ func createTestExecutor(agentMgr *mockAgentManager, log *logger.Logger, maxConcu
}
func createTestTask(id string, priority int) *v1.Task {
agentType := "test-agent"
agentProfileID := "test-agent"
return &v1.Task{
ID: id,
BoardID: "test-board",
Title: "Test Task " + id,
Priority: priority,
AgentType: &agentType,
State: v1.TaskStateTODO,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ID: id,
BoardID: "test-board",
Title: "Test Task " + id,
Priority: priority,
AgentProfileID: &agentProfileID,
State: v1.TaskStateTODO,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
@@ -278,10 +278,10 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
}
// StartTask manually starts agent execution for a task
func (s *Service) StartTask(ctx context.Context, taskID string, agentType string, priority int) (*executor.TaskExecution, error) {
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, priority int) (*executor.TaskExecution, error) {
s.logger.Info("manually starting task",
zap.String("task_id", taskID),
zap.String("agent_type", agentType),
zap.String("agent_profile_id", agentProfileID),
zap.Int("priority", priority))
// Fetch the task from the repository to get complete task info
@@ -293,9 +293,9 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentType string
return nil, err
}
// Override agent_type and priority if provided in the request
if agentType != "" {
task.AgentType = &agentType
// Override agent_profile_id and priority if provided in the request
if agentProfileID != "" {
task.AgentProfileID = &agentProfileID
}
if priority > 0 {
task.Priority = priority
@@ -425,9 +425,9 @@ func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskE
s.logger.Debug("handling task state changed",
zap.String("task_id", data.TaskID))
// Add task to queue if state is TODO and agent_type is set
// Add task to queue if state is TODO and agent_profile_id is set
if data.NewState != nil && *data.NewState == v1.TaskStateTODO {
if data.Task != nil && data.Task.AgentType != nil && *data.Task.AgentType != "" {
if data.Task != nil && data.Task.AgentProfileID != nil && *data.Task.AgentProfileID != "" {
if err := s.scheduler.EnqueueTask(data.Task); err != nil {
s.logger.Error("failed to enqueue task on state change",
zap.String("task_id", data.TaskID),
@@ -435,7 +435,7 @@ func (s *Service) handleTaskStateChanged(ctx context.Context, data watcher.TaskE
} else {
s.logger.Info("task enqueued on state change to TODO",
zap.String("task_id", data.TaskID),
zap.String("agent_type", *data.Task.AgentType))
zap.String("agent_profile_id", *data.Task.AgentProfileID))
}
}
}
@@ -28,7 +28,7 @@ type TaskEventData struct {
type AgentEventData struct {
TaskID string `json:"task_id"`
AgentInstanceID string `json:"agent_instance_id"`
AgentType string `json:"agent_type"`
AgentProfileID string `json:"agent_profile_id"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
@@ -73,7 +73,7 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque
Description: req.Description,
Priority: req.Priority,
State: req.State,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
AssignedTo: req.AssignedTo,
@@ -92,7 +92,7 @@ func (c *TaskController) UpdateTask(ctx context.Context, req dto.UpdateTaskReque
Description: req.Description,
Priority: req.Priority,
State: req.State,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
+5 -5
View File
@@ -100,7 +100,7 @@ type TaskDTO struct {
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
AgentType *string `json:"agent_type,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
RepositoryURL *string `json:"repository_url,omitempty"`
Branch *string `json:"branch,omitempty"`
AssignedAgentID *string `json:"assigned_agent_id,omitempty"`
@@ -311,9 +311,9 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO {
}
func FromTask(task *models.Task) TaskDTO {
var agentType *string
if task.AgentType != "" {
agentType = &task.AgentType
var agentProfileID *string
if task.AgentProfileID != "" {
agentProfileID = &task.AgentProfileID
}
var repositoryURL *string
if task.RepositoryURL != "" {
@@ -337,7 +337,7 @@ func FromTask(task *models.Task) TaskDTO {
Description: task.Description,
State: task.State,
Priority: task.Priority,
AgentType: agentType,
AgentProfileID: agentProfileID,
RepositoryURL: repositoryURL,
Branch: branch,
AssignedAgentID: assignedAgentID,
+2 -2
View File
@@ -236,7 +236,7 @@ type CreateTaskRequest struct {
Description string
Priority int
State *v1.TaskState
AgentType string
AgentProfileID string
RepositoryURL string
Branch string
AssignedTo string
@@ -250,7 +250,7 @@ type UpdateTaskRequest struct {
Description *string
Priority *int
State *v1.TaskState
AgentType *string
AgentProfileID *string
AssignedTo *string
Position *int
Metadata map[string]interface{}
@@ -80,7 +80,7 @@ type httpCreateTaskRequest struct {
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
AgentType string `json:"agent_type,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
@@ -110,7 +110,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
Description: body.Description,
Priority: body.Priority,
State: body.State,
AgentType: body.AgentType,
AgentProfileID: body.AgentProfileID,
RepositoryURL: body.RepositoryURL,
Branch: body.Branch,
AssignedTo: body.AssignedTo,
@@ -129,7 +129,7 @@ type httpUpdateTaskRequest struct {
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
AgentType *string `json:"agent_type,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
AssignedTo *string `json:"assigned_to,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
@@ -147,7 +147,7 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
Description: body.Description,
Priority: body.Priority,
State: body.State,
AgentType: body.AgentType,
AgentProfileID: body.AgentProfileID,
AssignedTo: body.AssignedTo,
Position: body.Position,
Metadata: body.Metadata,
@@ -228,7 +228,7 @@ type wsCreateTaskRequest struct {
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
AgentType string `json:"agent_type,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
@@ -265,7 +265,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
Description: req.Description,
Priority: req.Priority,
State: req.State,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
AssignedTo: req.AssignedTo,
@@ -305,7 +305,7 @@ type wsUpdateTaskRequest struct {
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
AgentType *string `json:"agent_type,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
AssignedTo *string `json:"assigned_to,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
@@ -326,7 +326,7 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M
Description: req.Description,
Priority: req.Priority,
State: req.State,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
AssignedTo: req.AssignedTo,
Position: req.Position,
Metadata: req.Metadata,
+5 -5
View File
@@ -16,7 +16,7 @@ type Task struct {
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
AgentType string `json:"agent_type,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
@@ -291,9 +291,9 @@ type Environment struct {
// ToAPI converts internal Task to API type
func (t *Task) ToAPI() *v1.Task {
var agentType *string
if t.AgentType != "" {
agentType = &t.AgentType
var agentProfileID *string
if t.AgentProfileID != "" {
agentProfileID = &t.AgentProfileID
}
var repositoryURL *string
@@ -319,7 +319,7 @@ func (t *Task) ToAPI() *v1.Task {
Description: t.Description,
State: t.State,
Priority: t.Priority,
AgentType: agentType,
AgentProfileID: agentProfileID,
RepositoryURL: repositoryURL,
Branch: branch,
AssignedAgentID: assignedAgentID,
@@ -40,17 +40,17 @@ func TestTaskStructInitialization(t *testing.T) {
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 5,
AgentType: "coding",
AssignedTo: "agent-001",
Position: 1,
Metadata: map[string]interface{}{"key": "value"},
CreatedAt: now,
UpdatedAt: now,
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 5,
AgentProfileID: "coding",
AssignedTo: "agent-001",
Position: 1,
Metadata: map[string]interface{}{"key": "value"},
CreatedAt: now,
UpdatedAt: now,
}
if task.ID != "task-123" {
@@ -77,8 +77,8 @@ func TestTaskStructInitialization(t *testing.T) {
if task.Priority != 5 {
t.Errorf("expected Priority 5, got %d", task.Priority)
}
if task.AgentType != "coding" {
t.Errorf("expected AgentType 'coding', got %s", task.AgentType)
if task.AgentProfileID != "coding" {
t.Errorf("expected AgentProfileID 'coding', got %s", task.AgentProfileID)
}
if task.AssignedTo != "agent-001" {
t.Errorf("expected AssignedTo 'agent-001', got %s", task.AssignedTo)
@@ -151,17 +151,17 @@ func TestTaskToAPI(t *testing.T) {
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateInProgress,
Priority: 3,
AgentType: "coding",
AssignedTo: "agent-001",
Position: 2,
Metadata: map[string]interface{}{"key": "value"},
CreatedAt: now,
UpdatedAt: now,
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateInProgress,
Priority: 3,
AgentProfileID: "coding",
AssignedTo: "agent-001",
Position: 2,
Metadata: map[string]interface{}{"key": "value"},
CreatedAt: now,
UpdatedAt: now,
}
apiTask := task.ToAPI()
@@ -187,8 +187,8 @@ func TestTaskToAPI(t *testing.T) {
if apiTask.Priority != task.Priority {
t.Errorf("expected Priority %d, got %d", task.Priority, apiTask.Priority)
}
if apiTask.AgentType == nil || *apiTask.AgentType != task.AgentType {
t.Errorf("expected AgentType %s, got %v", task.AgentType, apiTask.AgentType)
if apiTask.AgentProfileID == nil || *apiTask.AgentProfileID != task.AgentProfileID {
t.Errorf("expected AgentProfileID %s, got %v", task.AgentProfileID, apiTask.AgentProfileID)
}
if apiTask.AssignedAgentID == nil || *apiTask.AssignedAgentID != task.AssignedTo {
t.Errorf("expected AssignedAgentID %s, got %v", task.AssignedTo, apiTask.AssignedAgentID)
@@ -202,25 +202,25 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
now := time.Now().UTC()
task := &Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 0,
AgentType: "",
AssignedTo: "",
Position: 0,
Metadata: nil,
CreatedAt: now,
UpdatedAt: now,
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 0,
AgentProfileID: "",
AssignedTo: "",
Position: 0,
Metadata: nil,
CreatedAt: now,
UpdatedAt: now,
}
apiTask := task.ToAPI()
if apiTask.AgentType != nil {
t.Errorf("expected AgentType nil, got %v", apiTask.AgentType)
if apiTask.AgentProfileID != nil {
t.Errorf("expected AgentProfileID nil, got %v", apiTask.AgentProfileID)
}
if apiTask.AssignedAgentID != nil {
t.Errorf("expected AssignedAgentID nil, got %v", apiTask.AssignedAgentID)
+17 -17
View File
@@ -150,7 +150,7 @@ func (r *SQLiteRepository) initSchema() error {
description TEXT DEFAULT '',
state TEXT DEFAULT 'TODO',
priority INTEGER DEFAULT 0,
agent_type TEXT DEFAULT '',
agent_profile_id TEXT DEFAULT '',
repository_url TEXT DEFAULT '',
branch TEXT DEFAULT '',
assigned_to TEXT DEFAULT '',
@@ -793,9 +793,9 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er
}
_, err = tx.ExecContext(ctx, `
INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at)
INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, agent_profile_id, repository_url, branch, assigned_to, position, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
`, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentProfileID, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
if err != nil {
tx.Rollback()
return err
@@ -815,12 +815,12 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er
func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task, error) {
task := &models.Task{}
var metadata string
var agentType, repositoryURL, branch, assignedTo sql.NullString
var agentProfileID, repositoryURL, branch, assignedTo sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, agent_profile_id, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
FROM tasks WHERE id = ?
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentType, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentProfileID, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %s", id)
@@ -829,7 +829,7 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task
return nil, err
}
task.AgentType = agentType.String
task.AgentProfileID = agentProfileID.String
task.RepositoryURL = repositoryURL.String
task.Branch = branch.String
task.AssignedTo = assignedTo.String
@@ -847,9 +847,9 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er
}
result, err := r.db.ExecContext(ctx, `
UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_profile_id = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
`, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentProfileID, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
if err != nil {
return err
}
@@ -884,7 +884,7 @@ func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error {
// ListTasks returns all tasks for a board
func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_type, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_profile_id, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
tc.board_id, tc.column_id, tc.position
FROM tasks t
INNER JOIN task_columns tc ON tc.task_id = t.id
@@ -902,7 +902,7 @@ func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*mo
// ListTasksByColumn returns all tasks in a column
func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_type, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
SELECT t.id, t.workspace_id, t.title, t.description, t.state, t.priority, t.agent_profile_id, t.repository_url, t.branch, t.assigned_to, t.metadata, t.created_at, t.updated_at,
tc.board_id, tc.column_id, tc.position
FROM tasks t
INNER JOIN task_columns tc ON tc.task_id = t.id
@@ -922,12 +922,12 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
for rows.Next() {
task := &models.Task{}
var metadata string
var agentType, repositoryURL, branch, assignedTo sql.NullString
err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentType, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
var agentProfileID, repositoryURL, branch, assignedTo sql.NullString
err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentProfileID, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return nil, err
}
task.AgentType = agentType.String
task.AgentProfileID = agentProfileID.String
task.RepositoryURL = repositoryURL.String
task.Branch = branch.String
task.AssignedTo = assignedTo.String
@@ -942,7 +942,7 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas
for rows.Next() {
task := &models.Task{}
var metadata string
var agentType, repositoryURL, branch, assignedTo sql.NullString
var agentProfileID, repositoryURL, branch, assignedTo sql.NullString
err := rows.Scan(
&task.ID,
&task.WorkspaceID,
@@ -950,7 +950,7 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas
&task.Description,
&task.State,
&task.Priority,
&agentType,
&agentProfileID,
&repositoryURL,
&branch,
&assignedTo,
@@ -964,7 +964,7 @@ func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Tas
if err != nil {
return nil, err
}
task.AgentType = agentType.String
task.AgentProfileID = agentProfileID.String
task.RepositoryURL = repositoryURL.String
task.Branch = branch.String
task.AssignedTo = assignedTo.String
@@ -58,7 +58,7 @@ type CreateTaskRequest struct {
Description string `json:"description"`
Priority int `json:"priority"`
State *v1.TaskState `json:"state,omitempty"`
AgentType string `json:"agent_type,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
RepositoryURL string `json:"repository_url,omitempty"`
Branch string `json:"branch,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
@@ -72,7 +72,7 @@ type UpdateTaskRequest struct {
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
AgentType *string `json:"agent_type,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
AssignedTo *string `json:"assigned_to,omitempty"`
Position *int `json:"position,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
@@ -228,7 +228,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
Description: req.Description,
State: state,
Priority: req.Priority,
AgentType: req.AgentType,
AgentProfileID: req.AgentProfileID,
RepositoryURL: req.RepositoryURL,
Branch: req.Branch,
AssignedTo: req.AssignedTo,
@@ -276,8 +276,8 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
task.State = *req.State
stateChanged = true
}
if req.AgentType != nil {
task.AgentType = *req.AgentType
if req.AgentProfileID != nil {
task.AgentProfileID = *req.AgentProfileID
}
if req.AssignedTo != nil {
task.AssignedTo = *req.AssignedTo
@@ -1054,8 +1054,8 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
"updated_at": task.UpdatedAt.Format(time.RFC3339),
}
if task.AgentType != "" {
data["agent_type"] = task.AgentType
if task.AgentProfileID != "" {
data["agent_profile_id"] = task.AgentProfileID
}
if task.AssignedTo != "" {
data["assigned_to"] = task.AssignedTo
+1 -1
View File
@@ -26,7 +26,7 @@ type ResourceLimits struct {
type AgentInstance struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
AgentType string `json:"agent_type"`
AgentProfileID string `json:"agent_profile_id"`
ContainerID *string `json:"container_id,omitempty"`
ContainerName *string `json:"container_name,omitempty"`
Status AgentStatus `json:"status"`
+3 -3
View File
@@ -27,7 +27,7 @@ type Task struct {
Description string `json:"description"`
State TaskState `json:"state"`
Priority int `json:"priority"`
AgentType *string `json:"agent_type,omitempty"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
RepositoryURL *string `json:"repository_url,omitempty"`
Branch *string `json:"branch,omitempty"`
AssignedAgentID *string `json:"assigned_agent_id,omitempty"`
@@ -43,8 +43,8 @@ type Task struct {
type CreateTaskRequest struct {
Title string `json:"title" binding:"required,max=500"`
Description string `json:"description" binding:"required"`
Priority int `json:"priority" binding:"min=0,max=10"`
AgentType *string `json:"agent_type,omitempty"`
Priority int `json:"priority" binding:"min=0,max=10"`
AgentProfileID *string `json:"agent_profile_id,omitempty"`
RepositoryURL *string `json:"repository_url,omitempty"`
Branch *string `json:"branch,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
+4
View File
@@ -35,9 +35,13 @@ export default async function Page({ searchParams }: PageProps) {
items: workspaces.workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
description: workspace.description ?? null,
owner_id: workspace.owner_id,
default_executor_id: workspace.default_executor_id ?? null,
default_environment_id: workspace.default_environment_id ?? null,
default_agent_profile_id: workspace.default_agent_profile_id ?? null,
created_at: workspace.created_at,
updated_at: workspace.updated_at,
})),
activeId: activeWorkspaceId,
},
+9 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { IconCloud, IconServer } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
@@ -16,6 +16,14 @@ const EXECUTOR_TYPES = ['local_docker', 'remote_docker'] as const;
type ExecutorType = (typeof EXECUTOR_TYPES)[number];
export default function ExecutorCreatePage() {
return (
<Suspense fallback={<div className="p-4">Loading...</div>}>
<ExecutorCreatePageContent />
</Suspense>
);
}
function ExecutorCreatePageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const initialType = searchParams.get('type');
@@ -31,16 +31,24 @@ export function WorkspacesPageClient() {
{
id: created.id,
name: created.name,
description: created.description ?? null,
owner_id: created.owner_id,
default_executor_id: created.default_executor_id ?? null,
default_environment_id: created.default_environment_id ?? null,
default_agent_profile_id: created.default_agent_profile_id ?? null,
created_at: created.created_at,
updated_at: created.updated_at,
},
...items.map((workspace) => ({
id: workspace.id,
name: workspace.name,
description: workspace.description ?? null,
owner_id: workspace.owner_id,
default_executor_id: workspace.default_executor_id ?? null,
default_environment_id: workspace.default_environment_id ?? null,
default_agent_profile_id: workspace.default_agent_profile_id ?? null,
created_at: workspace.created_at,
updated_at: workspace.updated_at,
})),
]);
setNewWorkspaceName('');
+20 -4
View File
@@ -6,6 +6,10 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { TooltipProvider } from '@kandev/ui/tooltip';
import { Textarea } from '@kandev/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@kandev/ui/dialog';
import { Button } from '@kandev/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Label } from '@kandev/ui/label';
import { IconX } from '@tabler/icons-react';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { TaskChatPanel } from '@/components/task/task-chat-panel';
@@ -22,6 +26,13 @@ import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useRepositories } from '@/hooks/use-repositories';
import { useRepositoryBranches } from '@/hooks/use-repository-branches';
type AgentProfile = {
id: string;
name: string;
agent_id: string;
agent_name: string;
};
const AGENTS = [
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude Code' },
@@ -191,10 +202,14 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
const client = getWebSocketClient();
if (!client) return;
// Require agent_profile_id to be set on the task
if (!task.agent_profile_id) {
console.error('No agent profile configured for this task. Please edit the task to select an agent profile.');
return;
}
setIsAgentLoading(true);
try {
// Use task's agent_type if set, otherwise default to 'augment-agent'
const agentType = task.agent_type ?? 'augment-agent';
interface StartResponse {
success: boolean;
task_id: string;
@@ -205,7 +220,7 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
}
const response = await client.request<StartResponse>('orchestrator.start', {
task_id: task.id,
agent_type: agentType,
agent_profile_id: task.agent_profile_id,
}, 15000);
setIsAgentRunning(true);
@@ -219,7 +234,7 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
} finally {
setIsAgentLoading(false);
}
}, [task?.id, task?.agent_type]);
}, [task?.id, task?.agent_profile_id]);
const handleStopAgent = useCallback(async () => {
if (!task?.id) return;
@@ -344,6 +359,7 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
onSend={handleSendMessage}
isLoading={isLoadingComments}
isAgentWorking={isAgentRunning}
taskDescription={task.description}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
+1 -1
View File
@@ -229,7 +229,7 @@ export function TaskCreateDialog({
description: description.trim(),
repository_url: repository?.local_path ?? '',
branch: branch || '',
agent_type: startAgent ? 'default' : '',
agent_profile_id: agentProfileId || undefined,
environment_id: environmentId || undefined,
executor_id: executorId || undefined,
state: targetState,
+42 -8
View File
@@ -44,6 +44,7 @@ type TaskChatPanelProps = {
onSend: (message: string) => void;
isLoading?: boolean;
isAgentWorking?: boolean;
taskDescription?: string;
};
type ToolCallMetadata = {
@@ -199,7 +200,7 @@ function TypingIndicator() {
);
}
export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking }: TaskChatPanelProps) {
export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking, taskDescription }: TaskChatPanelProps) {
const [messageInput, setMessageInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
const [planModeEnabled, setPlanModeEnabled] = useState(false);
@@ -217,6 +218,21 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking }: Tas
(c) => c.type === 'message' || c.type === 'content' || c.type === 'tool_call' || !c.type
);
// Create a synthetic "user" message for the task description
const taskDescriptionMessage: Comment | null = taskDescription ? {
id: 'task-description',
task_id: commentsState?.taskId ?? '',
author_type: 'user',
content: taskDescription,
type: 'message',
created_at: '',
} : null;
// Combine task description with visible comments
const allMessages = taskDescriptionMessage
? [taskDescriptionMessage, ...visibleComments]
: visibleComments;
// Count agent messages to detect new responses
const agentMessageCount = visibleComments.filter((c) => c.author_type !== 'user').length;
@@ -265,12 +281,12 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking }: Tas
<span>Loading comments...</span>
</div>
)}
{!isLoading && !commentsLoading && visibleComments.length === 0 && (
{!isLoading && !commentsLoading && allMessages.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<span>No messages yet. Start the conversation!</span>
</div>
)}
{visibleComments.map((comment) => {
{allMessages.map((comment) => {
// Tool call comments have a special rendering
if (comment.type === 'tool_call') {
return <ToolCallCard key={comment.id} comment={comment} />;
@@ -278,18 +294,36 @@ export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking }: Tas
// Regular message or agent response - render as markdown
const isUser = comment.author_type === 'user';
const isTaskDescription = comment.id === 'task-description';
// Determine label and styling
let label: string;
let containerClass: string;
if (isTaskDescription) {
label = 'Task';
containerClass = 'ml-auto bg-primary/20 text-foreground border border-primary/40';
} else if (isUser) {
label = 'You';
containerClass = 'ml-auto bg-primary text-primary-foreground';
} else {
label = 'Agent';
containerClass = 'bg-muted text-foreground';
}
return (
<div
key={comment.id}
className={cn(
'max-w-[85%] rounded-lg px-4 py-3 text-sm',
isUser
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
containerClass
)}
>
<p className="text-[11px] uppercase tracking-wide mb-2 opacity-70">
{isUser ? 'You' : 'Agent'}
<p className={cn(
"text-[11px] uppercase tracking-wide mb-2",
isTaskDescription ? "text-primary font-medium" : "opacity-70"
)}>
{label}
</p>
{isUser ? (
<p className="whitespace-pre-wrap">{formatContent(comment)}</p>
+1 -1
View File
@@ -72,7 +72,7 @@ export function useUserDisplaySettings({
fetchUserSettings({ cache: 'no-store' })
.then((data) => {
if (!data?.settings) return;
const repositoryIds = Array.from(new Set(data.settings.repository_ids ?? [])).sort();
const repositoryIds = Array.from(new Set<string>(data.settings.repository_ids ?? [])).sort();
setUserSettings({
workspaceId: data.settings.workspace_id || null,
boardId: data.settings.board_id || null,
+2 -2
View File
@@ -93,7 +93,7 @@ export async function createTask(
position?: number;
repository_url?: string;
branch?: string;
agent_type?: string;
agent_profile_id?: string;
environment_id?: string;
executor_id?: string;
state?: Task['state'];
@@ -112,7 +112,7 @@ export async function updateTask(
title?: string;
description?: string;
position?: number;
agent_type?: string;
agent_profile_id?: string;
assigned_to?: string;
state?: Task['state'];
},
+9 -3
View File
@@ -3,6 +3,12 @@ import type { KanbanState } from '@/lib/state/store';
export type KanbanTask = KanbanState['tasks'][number];
// Minimal task type for filtering - only needs id and repositoryUrl
export type FilterableTask = {
id: string;
repositoryUrl?: string;
};
export function mapSelectedRepositoryPaths(
repositories: Repository[],
selectedIds: string[]
@@ -21,10 +27,10 @@ export function mapSelectedRepositoryPaths(
return paths;
}
export function filterTasksByRepositories(
tasks: KanbanTask[],
export function filterTasksByRepositories<T extends FilterableTask>(
tasks: T[],
selectedRepositoryPaths: Set<string>
): KanbanTask[] {
): T[] {
if (selectedRepositoryPaths.size === 0) {
return tasks;
}
+4
View File
@@ -30,9 +30,13 @@ export type WorkspaceState = {
items: Array<{
id: string;
name: string;
description?: string | null;
owner_id: string;
default_executor_id?: string | null;
default_environment_id?: string | null;
default_agent_profile_id?: string | null;
created_at: string;
updated_at: string;
}>;
activeId: string | null;
};
+1 -1
View File
@@ -79,7 +79,7 @@ export type Task = {
description: string;
state: TaskState;
priority: number;
agent_type?: string | null;
agent_profile_id?: string | null;
repository_url?: string | null;
branch?: string | null;
assigned_agent_id?: string | null;
+15 -4
View File
@@ -1,17 +1,28 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { Environment } from '@/lib/types/http';
export function registerEnvironmentsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'environment.created': (message) => {
const payload = message.payload;
const environment: Environment = {
id: payload.id,
name: payload.name,
kind: payload.kind,
is_system: payload.is_system,
worktree_root: payload.worktree_root ?? null,
image_tag: payload.image_tag ?? null,
dockerfile: payload.dockerfile ?? null,
build_config: payload.build_config ?? null,
created_at: payload.created_at ?? new Date().toISOString(),
updated_at: payload.updated_at ?? new Date().toISOString(),
};
store.setState((state) => ({
...state,
environments: {
items: [
...state.environments.items.filter((item) => item.id !== message.payload.id),
message.payload,
],
items: [...state.environments.items.filter((item) => item.id !== environment.id), environment],
},
}));
},
+13 -4
View File
@@ -1,17 +1,26 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { Executor } from '@/lib/types/http';
export function registerExecutorsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'executor.created': (message) => {
const payload = message.payload;
const executor: Executor = {
id: payload.id,
name: payload.name,
type: payload.type,
status: payload.status,
is_system: payload.is_system,
config: payload.config,
created_at: payload.created_at ?? new Date().toISOString(),
updated_at: payload.updated_at ?? new Date().toISOString(),
};
store.setState((state) => ({
...state,
executors: {
items: [
...state.executors.items.filter((item) => item.id !== message.payload.id),
message.payload,
],
items: [...state.executors.items.filter((item) => item.id !== executor.id), executor],
},
}));
},
+21 -24
View File
@@ -1,35 +1,30 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { AppState, WorkspaceState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
type WorkspaceItem = WorkspaceState['items'][number];
export function registerWorkspacesHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'workspace.created': (message) => {
store.setState((state) => {
const exists = state.workspaces.items.some((item) => item.id === message.payload.id);
const payload = message.payload;
const newWorkspace: WorkspaceItem = {
id: payload.id,
name: payload.name,
description: payload.description ?? null,
owner_id: payload.owner_id ?? '',
default_executor_id: payload.default_executor_id ?? null,
default_environment_id: payload.default_environment_id ?? null,
default_agent_profile_id: payload.default_agent_profile_id ?? null,
created_at: payload.created_at ?? new Date().toISOString(),
updated_at: payload.updated_at ?? new Date().toISOString(),
};
const exists = state.workspaces.items.some((item) => item.id === payload.id);
const items = exists
? state.workspaces.items.map((item) =>
item.id === message.payload.id
? {
...item,
name: message.payload.name,
default_executor_id: message.payload.default_executor_id ?? null,
default_environment_id: message.payload.default_environment_id ?? null,
default_agent_profile_id: message.payload.default_agent_profile_id ?? null,
}
: item
)
: [
{
id: message.payload.id,
name: message.payload.name,
default_executor_id: message.payload.default_executor_id ?? null,
default_environment_id: message.payload.default_environment_id ?? null,
default_agent_profile_id: message.payload.default_agent_profile_id ?? null,
},
...state.workspaces.items,
];
const activeId = state.workspaces.activeId ?? message.payload.id;
? state.workspaces.items.map((item) => (item.id === payload.id ? { ...item, ...newWorkspace } : item))
: [newWorkspace, ...state.workspaces.items];
const activeId = state.workspaces.activeId ?? payload.id;
return {
...state,
workspaces: {
@@ -49,9 +44,11 @@ export function registerWorkspacesHandlers(store: StoreApi<AppState>): WsHandler
? {
...item,
name: message.payload.name,
description: message.payload.description ?? item.description,
default_executor_id: message.payload.default_executor_id ?? null,
default_environment_id: message.payload.default_environment_id ?? null,
default_agent_profile_id: message.payload.default_agent_profile_id ?? null,
updated_at: message.payload.updated_at ?? item.updated_at,
}
: item
),