Refactor subtasks
Change-Id: I5ea6ffe266b8d6010de46bbf3bc6d7f861600f00
diff --git a/server/task/auto_assignment.go b/server/task/auto_assignment.go
new file mode 100644
index 0000000..4bd5632
--- /dev/null
+++ b/server/task/auto_assignment.go
@@ -0,0 +1,237 @@
+package task
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/iomodo/staff/config"
+ "github.com/iomodo/staff/tm"
+)
+
+// AutoAssigner handles intelligent task assignment to agents
+type AutoAssigner struct {
+ agents []config.AgentConfig
+}
+
+// NewAutoAssigner creates a new auto-assignment system
+func NewAutoAssigner(agents []config.AgentConfig) *AutoAssigner {
+ return &AutoAssigner{
+ agents: agents,
+ }
+}
+
+// AssignmentScore represents how well an agent matches a task
+type AssignmentScore struct {
+ AgentName string
+ Score float64
+ Reasons []string
+}
+
+// AssignTask automatically assigns a task to the best matching agent
+func (a *AutoAssigner) AssignTask(task *tm.Task) (string, error) {
+ if len(a.agents) == 0 {
+ return "", fmt.Errorf("no agents available for assignment")
+ }
+
+ scores := a.calculateScores(task)
+ if len(scores) == 0 {
+ return "", fmt.Errorf("no suitable agent found for task")
+ }
+
+ // Sort by score (highest first)
+ sort.Slice(scores, func(i, j int) bool {
+ return scores[i].Score > scores[j].Score
+ })
+
+ bestMatch := scores[0]
+ if bestMatch.Score == 0 {
+ // No good match, assign to CEO as fallback
+ return "ceo", nil
+ }
+
+ return bestMatch.AgentName, nil
+}
+
+// GetAssignmentRecommendations returns ranked recommendations for task assignment
+func (a *AutoAssigner) GetAssignmentRecommendations(task *tm.Task) []AssignmentScore {
+ scores := a.calculateScores(task)
+
+ // Sort by score (highest first)
+ sort.Slice(scores, func(i, j int) bool {
+ return scores[i].Score > scores[j].Score
+ })
+
+ return scores
+}
+
+// calculateScores calculates assignment scores for all agents
+func (a *AutoAssigner) calculateScores(task *tm.Task) []AssignmentScore {
+ scores := make([]AssignmentScore, 0, len(a.agents))
+
+ taskText := strings.ToLower(task.Title + " " + task.Description)
+ taskKeywords := extractKeywords(taskText)
+
+ for _, agent := range a.agents {
+ score := &AssignmentScore{
+ AgentName: agent.Name,
+ Score: 0,
+ Reasons: make([]string, 0),
+ }
+
+ // Score based on task type keywords
+ score.Score += a.scoreTaskTypes(agent.TaskTypes, taskKeywords, score)
+
+ // Score based on capabilities
+ score.Score += a.scoreCapabilities(agent.Capabilities, taskKeywords, score)
+
+ // Score based on task priority and agent model
+ score.Score += a.scorePriorityModelMatch(task.Priority, agent.Model, score)
+
+ // Score based on explicit agent mention in task
+ score.Score += a.scoreExplicitMention(agent.Name, agent.Role, taskText, score)
+
+ scores = append(scores, *score)
+ }
+
+ return scores
+}
+
+// scoreTaskTypes scores based on task type matching
+func (a *AutoAssigner) scoreTaskTypes(agentTypes []string, taskKeywords []string, score *AssignmentScore) float64 {
+ typeScore := 0.0
+
+ for _, agentType := range agentTypes {
+ for _, keyword := range taskKeywords {
+ if strings.Contains(keyword, agentType) || strings.Contains(agentType, keyword) {
+ typeScore += 3.0
+ score.Reasons = append(score.Reasons, fmt.Sprintf("matches task type: %s", agentType))
+ }
+ }
+ }
+
+ return typeScore
+}
+
+// scoreCapabilities scores based on capability matching
+func (a *AutoAssigner) scoreCapabilities(capabilities []string, taskKeywords []string, score *AssignmentScore) float64 {
+ capScore := 0.0
+
+ for _, capability := range capabilities {
+ for _, keyword := range taskKeywords {
+ if strings.Contains(keyword, capability) || strings.Contains(capability, keyword) {
+ capScore += 2.0
+ score.Reasons = append(score.Reasons, fmt.Sprintf("has capability: %s", capability))
+ }
+ }
+ }
+
+ return capScore
+}
+
+// scorePriorityModelMatch scores based on priority and model sophistication
+func (a *AutoAssigner) scorePriorityModelMatch(priority tm.TaskPriority, model string, score *AssignmentScore) float64 {
+ priorityScore := 0.0
+
+ // High priority tasks prefer more capable models
+ if priority == tm.PriorityHigh && strings.Contains(model, "gpt-4") {
+ priorityScore += 1.0
+ score.Reasons = append(score.Reasons, "high priority task matches advanced model")
+ }
+
+ // Medium/low priority can use efficient models
+ if priority != tm.PriorityHigh && strings.Contains(model, "gpt-3.5") {
+ priorityScore += 0.5
+ score.Reasons = append(score.Reasons, "priority matches model efficiency")
+ }
+
+ return priorityScore
+}
+
+// scoreExplicitMention scores based on explicit agent/role mentions
+func (a *AutoAssigner) scoreExplicitMention(agentName, agentRole, taskText string, score *AssignmentScore) float64 {
+ mentionScore := 0.0
+
+ // Check for explicit agent name mention
+ if strings.Contains(taskText, agentName) {
+ mentionScore += 5.0
+ score.Reasons = append(score.Reasons, "explicitly mentioned by name")
+ }
+
+ // Check for role mention
+ roleLower := strings.ToLower(agentRole)
+ if strings.Contains(taskText, roleLower) {
+ mentionScore += 4.0
+ score.Reasons = append(score.Reasons, "role mentioned in task")
+ }
+
+ return mentionScore
+}
+
+// extractKeywords extracts relevant keywords from task text
+func extractKeywords(text string) []string {
+ // Simple keyword extraction - split by common delimiters
+ words := strings.FieldsFunc(text, func(c rune) bool {
+ return c == ' ' || c == ',' || c == '.' || c == ':' || c == ';' || c == '\n' || c == '\t'
+ })
+
+ // Filter out common stop words and short words
+ stopWords := map[string]bool{
+ "the": true, "a": true, "an": true, "and": true, "or": true, "but": true,
+ "in": true, "on": true, "at": true, "to": true, "for": true, "of": true,
+ "with": true, "by": true, "is": true, "are": true, "was": true, "were": true,
+ "be": true, "been": true, "have": true, "has": true, "had": true, "do": true,
+ "does": true, "did": true, "will": true, "would": true, "could": true, "should": true,
+ }
+
+ keywords := make([]string, 0)
+ for _, word := range words {
+ word = strings.ToLower(strings.TrimSpace(word))
+ if len(word) > 2 && !stopWords[word] {
+ keywords = append(keywords, word)
+ }
+ }
+
+ return keywords
+}
+
+// ValidateAssignment checks if an assignment is valid
+func (a *AutoAssigner) ValidateAssignment(agentName string, task *tm.Task) error {
+ // Check if agent exists
+ for _, agent := range a.agents {
+ if agent.Name == agentName {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("agent '%s' not found", agentName)
+}
+
+// GetAgentCapabilities returns the capabilities of a specific agent
+func (a *AutoAssigner) GetAgentCapabilities(agentName string) ([]string, error) {
+ for _, agent := range a.agents {
+ if agent.Name == agentName {
+ return agent.Capabilities, nil
+ }
+ }
+
+ return nil, fmt.Errorf("agent '%s' not found", agentName)
+}
+
+// GetRecommendationExplanation returns a human-readable explanation for assignment
+func (a *AutoAssigner) GetRecommendationExplanation(task *tm.Task, agentName string) string {
+ recommendations := a.GetAssignmentRecommendations(task)
+
+ for _, rec := range recommendations {
+ if rec.AgentName == agentName {
+ if len(rec.Reasons) == 0 {
+ return fmt.Sprintf("Agent %s assigned (score: %.1f)", agentName, rec.Score)
+ }
+
+ reasons := strings.Join(rec.Reasons, ", ")
+ return fmt.Sprintf("Agent %s assigned (score: %.1f) because: %s", agentName, rec.Score, reasons)
+ }
+ }
+
+ return fmt.Sprintf("Agent %s assigned (manual override)", agentName)
+}
diff --git a/server/task/service.go b/server/task/service.go
new file mode 100644
index 0000000..ba6a4c4
--- /dev/null
+++ b/server/task/service.go
@@ -0,0 +1,850 @@
+package task
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/tm"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+// SubtaskService handles subtask generation and management
+type SubtaskService struct {
+ llmProvider llm.LLMProvider
+ taskManager tm.TaskManager
+ agentRoles []string // Available agent roles for assignment
+ prProvider git.PullRequestProvider // GitHub PR provider
+ githubOwner string
+ githubRepo string
+ cloneManager *git.CloneManager
+ logger *slog.Logger
+}
+
+// NewSubtaskService creates a new subtask service
+func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager, logger *slog.Logger) *SubtaskService {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &SubtaskService{
+ llmProvider: provider,
+ taskManager: taskManager,
+ agentRoles: agentRoles,
+ prProvider: prProvider,
+ githubOwner: githubOwner,
+ githubRepo: githubRepo,
+ cloneManager: cloneManager,
+ logger: logger,
+ }
+}
+
+// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
+func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
+ prompt := s.buildSubtaskDecisionPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: "gpt-4",
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: s.getSubtaskDecisionSystemPrompt(),
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: &[]int{1000}[0],
+ Temperature: &[]float64{0.3}[0],
+ }
+
+ resp, err := s.llmProvider.ChatCompletion(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("LLM decision failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return nil, fmt.Errorf("no response from LLM")
+ }
+
+ // Parse the LLM response
+ decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
+ }
+
+ return decision, nil
+}
+
+// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
+func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
+ prompt := s.buildSubtaskAnalysisPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: "gpt-4",
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: s.getSubtaskAnalysisSystemPrompt(),
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: &[]int{4000}[0],
+ Temperature: &[]float64{0.3}[0],
+ }
+
+ resp, err := s.llmProvider.ChatCompletion(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("LLM analysis failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return nil, fmt.Errorf("no response from LLM")
+ }
+
+ // Parse the LLM response
+ analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse LLM response: %w", err)
+ }
+
+ return analysis, nil
+}
+
+// getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision
+func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string {
+ availableRoles := strings.Join(s.agentRoles, ", ")
+
+ return fmt.Sprintf(`You are an expert project manager and task analyst. Your job is to determine whether a task needs to be broken down into subtasks.
+
+Currently available team roles and their capabilities: %s
+
+When evaluating a task, consider:
+1. Task complexity and scope
+2. Whether multiple specialized skills are needed
+3. If the task can be completed by a single agent with current capabilities
+4. Whether new agent roles might be needed for specialized skills
+
+Respond with a JSON object in this exact format:
+{
+ "needs_subtasks": true/false,
+ "reasoning": "Clear explanation of why subtasks are or aren't needed",
+ "complexity_score": 5,
+ "required_skills": ["skill1", "skill2", "skill3"]
+}
+
+Complexity score should be 1-10 where:
+- 1-3: Simple tasks that can be handled by one agent
+- 4-6: Moderate complexity, might benefit from subtasks
+- 7-10: Complex tasks that definitely need breaking down
+
+Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
+}
+
+// getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis
+func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string {
+ availableRoles := strings.Join(s.agentRoles, ", ")
+
+ return fmt.Sprintf(`You are an expert project manager and technical architect. Your job is to analyze complex tasks and break them down into well-defined subtasks that can be assigned to specialized team members.
+
+Currently available team roles: %s
+
+When analyzing a task, you should:
+1. Understand the task requirements and scope
+2. Break it down into logical, manageable subtasks
+3. Assign each subtask to the most appropriate team role OR propose creating new agents
+4. Estimate effort and identify dependencies
+5. Provide a clear execution strategy
+
+If you need specialized skills not covered by existing roles, propose new agent creation.
+
+Respond with a JSON object in this exact format:
+{
+ "analysis_summary": "Brief analysis of the task and approach",
+ "subtasks": [
+ {
+ "title": "Subtask title",
+ "description": "Detailed description of what needs to be done",
+ "priority": "high|medium|low",
+ "assigned_to": "role_name",
+ "estimated_hours": 8,
+ "dependencies": ["subtask_index_1", "subtask_index_2"],
+ "required_skills": ["skill1", "skill2"]
+ }
+ ],
+ "agent_creations": [
+ {
+ "role": "new_role_name",
+ "skills": ["specialized_skill1", "specialized_skill2"],
+ "description": "Description of what this agent does",
+ "justification": "Why this new agent is needed"
+ }
+ ],
+ "recommended_approach": "High-level strategy for executing these subtasks",
+ "estimated_total_hours": 40,
+ "risk_assessment": "Potential risks and mitigation strategies"
+}
+
+For existing roles, use: %s
+For new agents, propose appropriate role names and skill sets.
+Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
+}
+
+// buildSubtaskDecisionPrompt creates the user prompt for subtask decision
+func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
+
+**Task Title:** %s
+
+**Description:** %s
+
+**Priority:** %s
+
+**Current Status:** %s
+
+Consider:
+- Can this be completed by a single agent with existing capabilities?
+- Does it require multiple specialized skills?
+- Is the scope too large for one person?
+- Are there logical components that could be parallelized?
+
+Provide your decision in the JSON format specified in the system prompt.`,
+ task.Title,
+ task.Description,
+ task.Priority,
+ task.Status)
+}
+
+// buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis
+func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
+
+**Task Title:** %s
+
+**Description:** %s
+
+**Priority:** %s
+
+**Current Status:** %s
+
+Please analyze this task and provide a detailed breakdown into subtasks. Consider:
+- Technical complexity and requirements
+- Logical task dependencies
+- Appropriate skill sets needed for each subtask
+- Risk factors and potential blockers
+- Estimated effort for each component
+
+Provide the analysis in the JSON format specified in the system prompt.`,
+ task.Title,
+ task.Description,
+ task.Priority,
+ task.Status)
+}
+
+// parseSubtaskDecision parses the LLM response into a SubtaskDecision struct
+func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
+ // Try to extract JSON from the response
+ jsonStart := strings.Index(response, "{")
+ jsonEnd := strings.LastIndex(response, "}")
+
+ if jsonStart == -1 || jsonEnd == -1 {
+ return nil, fmt.Errorf("no JSON found in LLM response")
+ }
+
+ jsonStr := response[jsonStart : jsonEnd+1]
+
+ var decision tm.SubtaskDecision
+ if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
+ }
+
+ return &decision, nil
+}
+
+// parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct
+func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) {
+ // Try to extract JSON from the response (LLM might wrap it in markdown)
+ jsonStart := strings.Index(response, "{")
+ jsonEnd := strings.LastIndex(response, "}")
+
+ if jsonStart == -1 || jsonEnd == -1 {
+ return nil, fmt.Errorf("no JSON found in LLM response")
+ }
+
+ jsonStr := response[jsonStart : jsonEnd+1]
+
+ var rawAnalysis struct {
+ AnalysisSummary string `json:"analysis_summary"`
+ Subtasks []struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Priority string `json:"priority"`
+ AssignedTo string `json:"assigned_to"`
+ EstimatedHours int `json:"estimated_hours"`
+ Dependencies []string `json:"dependencies"`
+ RequiredSkills []string `json:"required_skills"`
+ } `json:"subtasks"`
+ AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
+ RecommendedApproach string `json:"recommended_approach"`
+ EstimatedTotalHours int `json:"estimated_total_hours"`
+ RiskAssessment string `json:"risk_assessment"`
+ }
+
+ if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
+ }
+
+ // Convert to our types
+ analysis := &tm.SubtaskAnalysis{
+ ParentTaskID: parentTaskID,
+ AnalysisSummary: rawAnalysis.AnalysisSummary,
+ AgentCreations: rawAnalysis.AgentCreations,
+ RecommendedApproach: rawAnalysis.RecommendedApproach,
+ EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
+ RiskAssessment: rawAnalysis.RiskAssessment,
+ }
+
+ // Convert subtasks
+ for _, st := range rawAnalysis.Subtasks {
+ priority := tm.PriorityMedium // default
+ switch strings.ToLower(st.Priority) {
+ case "high":
+ priority = tm.PriorityHigh
+ case "low":
+ priority = tm.PriorityLow
+ }
+
+ subtask := tm.SubtaskProposal{
+ Title: st.Title,
+ Description: st.Description,
+ Priority: priority,
+ AssignedTo: st.AssignedTo,
+ EstimatedHours: st.EstimatedHours,
+ Dependencies: st.Dependencies,
+ RequiredSkills: st.RequiredSkills,
+ }
+
+ analysis.Subtasks = append(analysis.Subtasks, subtask)
+ }
+
+ // Validate agent assignments and handle new agent creation
+ if err := s.validateAndHandleAgentAssignments(analysis); err != nil {
+ s.logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error()))
+ }
+
+ return analysis, nil
+}
+
+// validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed
+func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error {
+ // Collect all agent roles that will be available (existing + proposed new ones)
+ availableRoles := make(map[string]bool)
+ for _, role := range s.agentRoles {
+ availableRoles[role] = true
+ }
+
+ // Add proposed new agent roles
+ for _, agentCreation := range analysis.AgentCreations {
+ availableRoles[agentCreation.Role] = true
+
+ // Create a subtask for agent creation
+ agentCreationSubtask := tm.SubtaskProposal{
+ Title: fmt.Sprintf("Create %s Agent", cases.Title(language.English).String(agentCreation.Role)),
+ Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
+ Priority: tm.PriorityHigh, // Agent creation is high priority
+ AssignedTo: "ceo", // CEO creates new agents
+ EstimatedHours: 4, // Estimated time to set up new agent
+ Dependencies: []string{}, // No dependencies for agent creation
+ RequiredSkills: []string{"agent_configuration", "system_design"},
+ }
+
+ // Insert at the beginning so agent creation happens first
+ analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
+
+ // Update dependencies to account for the new subtask at index 0
+ for i := 1; i < len(analysis.Subtasks); i++ {
+ for j, dep := range analysis.Subtasks[i].Dependencies {
+ // Convert dependency index and increment by 1
+ if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
+ analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
+ }
+ }
+ }
+ }
+
+ // Now validate all assignments against available roles
+ defaultRole := "ceo" // fallback role
+ if len(s.agentRoles) > 0 {
+ defaultRole = s.agentRoles[0]
+ }
+
+ for i := range analysis.Subtasks {
+ if !availableRoles[analysis.Subtasks[i].AssignedTo] {
+ s.logger.Warn("Unknown agent role for subtask, using default",
+ slog.String("unknown_role", analysis.Subtasks[i].AssignedTo),
+ slog.String("subtask_title", analysis.Subtasks[i].Title),
+ slog.String("assigned_role", defaultRole))
+ analysis.Subtasks[i].AssignedTo = defaultRole
+ }
+ }
+
+ return nil
+}
+
+// parseDependencyIndex parses a dependency string to an integer index
+func (s *SubtaskService) parseDependencyIndex(dep string) int {
+ var idx int
+ if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
+ return idx
+ }
+ return -1 // Invalid dependency format
+}
+
+// isValidAgentRole checks if a role is in the available agent roles
+func (s *SubtaskService) isValidAgentRole(role string) bool {
+ for _, availableRole := range s.agentRoles {
+ if availableRole == role {
+ return true
+ }
+ }
+ return false
+}
+
+// GenerateSubtaskPR creates a PR with the proposed subtasks
+func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
+ if s.prProvider == nil {
+ return "", fmt.Errorf("PR provider not configured")
+ }
+
+ // Generate branch name for subtask proposal
+ branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID)
+ s.logger.Info("Creating subtask PR", slog.String("branch", branchName))
+
+ // Create Git branch and commit subtask proposal
+ if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil {
+ return "", fmt.Errorf("failed to create subtask branch: %w", err)
+ }
+
+ // Generate PR content
+ prContent := s.generateSubtaskPRContent(analysis)
+ title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
+
+ // Validate PR content
+ if title == "" {
+ return "", fmt.Errorf("PR title cannot be empty")
+ }
+ if prContent == "" {
+ return "", fmt.Errorf("PR description cannot be empty")
+ }
+
+ // Determine base branch (try main first, fallback to master)
+ baseBranch := s.determineBaseBranch(ctx)
+ s.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
+
+ // Create the pull request
+ options := git.PullRequestOptions{
+ Title: title,
+ Description: prContent,
+ HeadBranch: branchName,
+ BaseBranch: baseBranch,
+ Labels: []string{"subtasks", "proposal", "ai-generated"},
+ Draft: false,
+ }
+
+ s.logger.Info("Creating PR with options",
+ slog.String("title", options.Title),
+ slog.String("head_branch", options.HeadBranch),
+ slog.String("base_branch", options.BaseBranch))
+
+ pr, err := s.prProvider.CreatePullRequest(ctx, options)
+ if err != nil {
+ return "", fmt.Errorf("failed to create PR: %w", err)
+ }
+
+ prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number)
+ s.logger.Info("Generated subtask proposal PR", slog.String("pr_url", prURL))
+
+ return prURL, nil
+}
+
+// determineBaseBranch determines the correct base branch (main or master)
+func (s *SubtaskService) determineBaseBranch(ctx context.Context) string {
+ if s.cloneManager == nil {
+ return "main" // default fallback
+ }
+
+ // Get clone path to check branches
+ clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
+ if err != nil {
+ s.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
+ return "main"
+ }
+
+ // Check if main branch exists
+ gitCmd := func(args ...string) *exec.Cmd {
+ return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
+ }
+
+ // Try to checkout main branch
+ cmd := gitCmd("show-ref", "refs/remotes/origin/main")
+ if err := cmd.Run(); err == nil {
+ return "main"
+ }
+
+ // Try to checkout master branch
+ cmd = gitCmd("show-ref", "refs/remotes/origin/master")
+ if err := cmd.Run(); err == nil {
+ return "master"
+ }
+
+ // Default to main if neither can be detected
+ s.logger.Warn("Could not determine base branch, defaulting to 'main'")
+ return "main"
+}
+
+// generateSubtaskFile creates the content for an individual subtask file
+func (s *SubtaskService) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
+ var content strings.Builder
+
+ // Generate YAML frontmatter
+ content.WriteString("---\n")
+ content.WriteString(fmt.Sprintf("id: %s\n", taskID))
+ content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
+ content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
+ content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
+ content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
+ content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
+ content.WriteString("status: todo\n")
+ content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
+ content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
+ content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
+ content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
+ content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
+ content.WriteString("completed_at: null\n")
+ content.WriteString("archived_at: null\n")
+
+ // Add dependencies if any
+ if len(subtask.Dependencies) > 0 {
+ content.WriteString("dependencies:\n")
+ for _, dep := range subtask.Dependencies {
+ // Convert dependency index to actual subtask ID
+ if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
+ depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
+ content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
+ }
+ }
+ }
+
+ // Add required skills if any
+ if len(subtask.RequiredSkills) > 0 {
+ content.WriteString("required_skills:\n")
+ for _, skill := range subtask.RequiredSkills {
+ content.WriteString(fmt.Sprintf(" - %s\n", skill))
+ }
+ }
+
+ content.WriteString("---\n\n")
+
+ // Add markdown content
+ content.WriteString("# Task Description\n\n")
+ content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
+
+ if subtask.EstimatedHours > 0 {
+ content.WriteString("## Estimated Effort\n\n")
+ content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
+ }
+
+ if len(subtask.RequiredSkills) > 0 {
+ content.WriteString("## Required Skills\n\n")
+ for _, skill := range subtask.RequiredSkills {
+ content.WriteString(fmt.Sprintf("- %s\n", skill))
+ }
+ content.WriteString("\n")
+ }
+
+ if len(subtask.Dependencies) > 0 {
+ content.WriteString("## Dependencies\n\n")
+ content.WriteString("This task depends on the completion of:\n\n")
+ for _, dep := range subtask.Dependencies {
+ if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
+ depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
+ content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
+ }
+ }
+ content.WriteString("\n")
+ }
+
+ content.WriteString("## Notes\n\n")
+ content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
+ content.WriteString("Generated by Staff AI Agent System\n\n")
+
+ return content.String()
+}
+
+// updateParentTaskAsCompleted updates the parent task file to mark it as completed
+func (s *SubtaskService) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
+ // Read the existing parent task file
+ content, err := os.ReadFile(taskFilePath)
+ if err != nil {
+ return fmt.Errorf("failed to read parent task file: %w", err)
+ }
+
+ taskContent := string(content)
+
+ // Find the YAML frontmatter boundaries
+ lines := strings.Split(taskContent, "\n")
+ var frontmatterStart, frontmatterEnd int = -1, -1
+
+ for i, line := range lines {
+ if line == "---" {
+ if frontmatterStart == -1 {
+ frontmatterStart = i
+ } else {
+ frontmatterEnd = i
+ break
+ }
+ }
+ }
+
+ if frontmatterStart == -1 || frontmatterEnd == -1 {
+ return fmt.Errorf("invalid task file format: missing YAML frontmatter")
+ }
+
+ // Update the frontmatter
+ now := time.Now().Format(time.RFC3339)
+ var updatedLines []string
+
+ // Add lines before frontmatter
+ updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
+
+ // Process frontmatter lines
+ for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
+ line := lines[i]
+ if strings.HasPrefix(line, "status:") {
+ updatedLines = append(updatedLines, "status: completed")
+ } else if strings.HasPrefix(line, "updated_at:") {
+ updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
+ } else if strings.HasPrefix(line, "completed_at:") {
+ updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
+ } else {
+ updatedLines = append(updatedLines, line)
+ }
+ }
+
+ // Add closing frontmatter and rest of content
+ updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
+
+ // Add subtask information to the task description
+ if frontmatterEnd+1 < len(lines) {
+ // Add subtask information
+ subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
+ for i, subtask := range analysis.Subtasks {
+ subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
+ subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
+ }
+ subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
+ subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
+
+ // Insert subtask info before any existing body content
+ updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo
+
+ // Write the updated content back to the file
+ if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
+ return fmt.Errorf("failed to write updated parent task file: %w", err)
+ }
+ }
+
+ s.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
+ return nil
+}
+
+// generateSubtaskPRContent creates markdown content for the subtask proposal PR
+func (s *SubtaskService) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
+ var content strings.Builder
+
+ content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
+ content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
+ content.WriteString(fmt.Sprintf("✅ **Parent task `%s` has been marked as completed** - the complex task has been successfully broken down into actionable subtasks.\n\n", analysis.ParentTaskID))
+ content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
+ content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
+ content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
+
+ // List the created task files
+ content.WriteString("## Created Task Files\n\n")
+ for i, subtask := range analysis.Subtasks {
+ taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
+ content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
+ content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
+ content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
+ content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
+ content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
+ content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
+ }
+
+ if analysis.RiskAssessment != "" {
+ content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
+ }
+
+ content.WriteString("## Proposed Subtasks\n\n")
+
+ for i, subtask := range analysis.Subtasks {
+ content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
+ content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
+ content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
+ content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
+
+ if len(subtask.Dependencies) > 0 {
+ deps := strings.Join(subtask.Dependencies, ", ")
+ content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
+ }
+
+ content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
+ }
+
+ content.WriteString("---\n")
+ content.WriteString("*Generated by Staff AI Agent System*\n\n")
+ content.WriteString("**Instructions:**\n")
+ content.WriteString("- Review the proposed subtasks\n")
+ content.WriteString("- Approve or request changes\n")
+ content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
+
+ return content.String()
+}
+
+// createSubtaskBranch creates a Git branch with subtask proposal content
+func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
+ if s.cloneManager == nil {
+ return fmt.Errorf("clone manager not configured")
+ }
+
+ // Get a temporary clone for creating the subtask branch
+ clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
+ if err != nil {
+ return fmt.Errorf("failed to get clone path: %w", err)
+ }
+
+ // All Git operations use the clone directory
+ gitCmd := func(args ...string) *exec.Cmd {
+ return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
+ }
+
+ // Ensure we're on main branch before creating new branch
+ cmd := gitCmd("checkout", "main")
+ if err := cmd.Run(); err != nil {
+ // Try master branch if main doesn't exist
+ cmd = gitCmd("checkout", "master")
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to checkout main/master branch: %w", err)
+ }
+ }
+
+ // Pull latest changes
+ cmd = gitCmd("pull", "origin")
+ if err := cmd.Run(); err != nil {
+ s.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
+ }
+
+ // Delete branch if it exists (cleanup from previous attempts)
+ cmd = gitCmd("branch", "-D", branchName)
+ _ = cmd.Run() // Ignore error if branch doesn't exist
+
+ // Also delete remote tracking branch if it exists
+ cmd = gitCmd("push", "origin", "--delete", branchName)
+ _ = cmd.Run() // Ignore error if branch doesn't exist
+
+ // Create and checkout new branch
+ cmd = gitCmd("checkout", "-b", branchName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to create branch: %w", err)
+ }
+
+ // Create individual task files for each subtask
+ tasksDir := filepath.Join(clonePath, "operations", "tasks")
+ if err := os.MkdirAll(tasksDir, 0755); err != nil {
+ return fmt.Errorf("failed to create tasks directory: %w", err)
+ }
+
+ var stagedFiles []string
+
+ // Update parent task to mark as completed
+ parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
+ if err := s.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
+ return fmt.Errorf("failed to update parent task: %w", err)
+ }
+
+ // Track parent task file for staging
+ parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
+ stagedFiles = append(stagedFiles, parentRelativeFile)
+ s.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
+
+ // Create a file for each subtask
+ for i, subtask := range analysis.Subtasks {
+ taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
+ taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
+ taskContent := s.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
+
+ if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
+ return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
+ }
+
+ // Track file for staging
+ relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
+ stagedFiles = append(stagedFiles, relativeFile)
+ s.logger.Info("Created subtask file", slog.String("file", relativeFile))
+ }
+
+ // Stage all subtask files
+ for _, file := range stagedFiles {
+ cmd = gitCmd("add", file)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to stage file %s: %w", file, err)
+ }
+ }
+
+ // Commit changes
+ commitMsg := fmt.Sprintf("Create %d subtasks for task %s and mark parent as completed\n\nGenerated by Staff AI Agent System\n\nFiles modified:\n- %s.md (marked as completed)\n\nCreated individual task files:\n",
+ len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
+
+ // Add list of created files to commit message
+ for i := range analysis.Subtasks {
+ taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
+ commitMsg += fmt.Sprintf("- %s.md\n", taskID)
+ }
+
+ if len(analysis.AgentCreations) > 0 {
+ commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
+ }
+ cmd = gitCmd("commit", "-m", commitMsg)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to commit: %w", err)
+ }
+
+ // Push branch
+ cmd = gitCmd("push", "-u", "origin", branchName)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to push branch: %w", err)
+ }
+
+ s.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
+ return nil
+}
+
+// Close cleans up the service
+func (s *SubtaskService) Close() error {
+ if s.llmProvider != nil {
+ return s.llmProvider.Close()
+ }
+ return nil
+}
diff --git a/server/task/service_test.go b/server/task/service_test.go
new file mode 100644
index 0000000..5c7b337
--- /dev/null
+++ b/server/task/service_test.go
@@ -0,0 +1,687 @@
+package task
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/tm"
+)
+
+// MockLLMProvider implements a mock LLM provider for testing
+type MockLLMProvider struct {
+ responses []string
+ callCount int
+}
+
+func NewMockLLMProvider(responses []string) *MockLLMProvider {
+ return &MockLLMProvider{
+ responses: responses,
+ callCount: 0,
+ }
+}
+
+func (m *MockLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error) {
+ if m.callCount >= len(m.responses) {
+ return nil, nil
+ }
+
+ response := m.responses[m.callCount]
+ m.callCount++
+
+ return &llm.ChatCompletionResponse{
+ ID: "mock-response",
+ Object: "chat.completion",
+ Created: time.Now().Unix(),
+ Model: req.Model,
+ Choices: []llm.ChatCompletionChoice{
+ {
+ Index: 0,
+ Message: llm.Message{
+ Role: llm.RoleAssistant,
+ Content: response,
+ },
+ FinishReason: "stop",
+ },
+ },
+ Usage: llm.Usage{
+ PromptTokens: 100,
+ CompletionTokens: 300,
+ TotalTokens: 400,
+ },
+ }, nil
+}
+
+func (m *MockLLMProvider) CreateEmbeddings(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
+ return &llm.EmbeddingResponse{
+ Object: "list",
+ Data: []llm.Embedding{
+ {
+ Object: "embedding",
+ Index: 0,
+ Embedding: make([]float64, 1536),
+ },
+ },
+ Model: req.Model,
+ Usage: llm.Usage{
+ PromptTokens: 50,
+ TotalTokens: 50,
+ },
+ }, nil
+}
+
+func (m *MockLLMProvider) Close() error {
+ return nil
+}
+
+func TestNewSubtaskService(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ agentRoles := []string{"backend", "frontend", "qa"}
+
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ if service == nil {
+ t.Fatal("NewSubtaskService returned nil")
+ }
+
+ if service.llmProvider != mockProvider {
+ t.Error("LLM provider not set correctly")
+ }
+
+ if len(service.agentRoles) != 3 {
+ t.Errorf("Expected 3 agent roles, got %d", len(service.agentRoles))
+ }
+}
+
+func TestShouldGenerateSubtasks(t *testing.T) {
+ // Test decision to generate subtasks
+ decisionResponse := `{
+ "needs_subtasks": true,
+ "reasoning": "Complex task requiring multiple skills",
+ "complexity_score": 8,
+ "required_skills": ["backend", "frontend", "database"]
+}`
+
+ mockProvider := NewMockLLMProvider([]string{decisionResponse})
+ agentRoles := []string{"backend", "frontend", "qa"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ // Test the parseSubtaskDecision method directly since ShouldGenerateSubtasks is used by manager
+ decision, err := service.parseSubtaskDecision(decisionResponse)
+ if err != nil {
+ t.Fatalf("parseSubtaskDecision failed: %v", err)
+ }
+
+ if !decision.NeedsSubtasks {
+ t.Error("Expected decision to need subtasks")
+ }
+
+ if decision.ComplexityScore != 8 {
+ t.Errorf("Expected complexity score 8, got %d", decision.ComplexityScore)
+ }
+
+ if len(decision.RequiredSkills) != 3 {
+ t.Errorf("Expected 3 required skills, got %d", len(decision.RequiredSkills))
+ }
+}
+
+func TestAnalyzeTaskForSubtasks(t *testing.T) {
+ jsonResponse := `{
+ "analysis_summary": "This task requires breaking down into multiple components",
+ "subtasks": [
+ {
+ "title": "Backend Development",
+ "description": "Implement server-side logic",
+ "priority": "high",
+ "assigned_to": "backend",
+ "estimated_hours": 16,
+ "dependencies": [],
+ "required_skills": ["go", "api_development"]
+ },
+ {
+ "title": "Frontend Development",
+ "description": "Build user interface",
+ "priority": "medium",
+ "assigned_to": "frontend",
+ "estimated_hours": 12,
+ "dependencies": ["0"],
+ "required_skills": ["react", "typescript"]
+ }
+ ],
+ "agent_creations": [
+ {
+ "role": "security_specialist",
+ "skills": ["security_audit", "penetration_testing"],
+ "description": "Specialized agent for security tasks",
+ "justification": "Authentication requires security expertise"
+ }
+ ],
+ "recommended_approach": "Start with backend then frontend",
+ "estimated_total_hours": 28,
+ "risk_assessment": "Medium complexity with API integration risks"
+}`
+
+ mockProvider := NewMockLLMProvider([]string{jsonResponse})
+ agentRoles := []string{"backend", "frontend", "qa", "ceo"} // Include CEO for agent creation
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Build authentication system",
+ Description: "Implement user login and registration",
+ Priority: tm.PriorityHigh,
+ Status: tm.StatusToDo,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ analysis, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
+ if err != nil {
+ t.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
+ }
+
+ if analysis.ParentTaskID != task.ID {
+ t.Errorf("Expected parent task ID %s, got %s", task.ID, analysis.ParentTaskID)
+ }
+
+ if analysis.AnalysisSummary == "" {
+ t.Error("Analysis summary should not be empty")
+ }
+
+ // Should have 3 subtasks (1 for agent creation + 2 original)
+ if len(analysis.Subtasks) != 3 {
+ t.Errorf("Expected 3 subtasks (including agent creation), got %d", len(analysis.Subtasks))
+ t.Logf("Subtasks: %+v", analysis.Subtasks)
+ return // Exit early if count is wrong to avoid index errors
+ }
+
+ // Test agent creation was processed
+ if len(analysis.AgentCreations) != 1 {
+ t.Errorf("Expected 1 agent creation, got %d", len(analysis.AgentCreations))
+ } else {
+ agentCreation := analysis.AgentCreations[0]
+ if agentCreation.Role != "security_specialist" {
+ t.Errorf("Expected role 'security_specialist', got %s", agentCreation.Role)
+ }
+ if len(agentCreation.Skills) != 2 {
+ t.Errorf("Expected 2 skills, got %d", len(agentCreation.Skills))
+ }
+ }
+
+ // We already checked the count above
+
+ // Test first subtask (agent creation)
+ subtask0 := analysis.Subtasks[0]
+ if !strings.Contains(subtask0.Title, "Security_specialist") {
+ t.Errorf("Expected agent creation subtask for security_specialist, got %s", subtask0.Title)
+ }
+ if subtask0.AssignedTo != "ceo" {
+ t.Errorf("Expected agent creation assigned to 'ceo', got %s", subtask0.AssignedTo)
+ }
+
+ // Test second subtask (original backend task, now at index 1)
+ subtask1 := analysis.Subtasks[1]
+ if subtask1.Title != "Backend Development" {
+ t.Errorf("Expected title 'Backend Development', got %s", subtask1.Title)
+ }
+ if subtask1.Priority != tm.PriorityHigh {
+ t.Errorf("Expected high priority, got %s", subtask1.Priority)
+ }
+ if subtask1.AssignedTo != "backend" {
+ t.Errorf("Expected assigned_to 'backend', got %s", subtask1.AssignedTo)
+ }
+ if subtask1.EstimatedHours != 16 {
+ t.Errorf("Expected 16 hours, got %d", subtask1.EstimatedHours)
+ }
+ if len(subtask1.RequiredSkills) != 2 {
+ t.Errorf("Expected 2 required skills, got %d", len(subtask1.RequiredSkills))
+ }
+
+ // Test third subtask (original frontend task, now at index 2 with updated dependencies)
+ subtask2 := analysis.Subtasks[2]
+ if subtask2.Title != "Frontend Development" {
+ t.Errorf("Expected title 'Frontend Development', got %s", subtask2.Title)
+ }
+ if subtask2.Priority != tm.PriorityMedium {
+ t.Errorf("Expected medium priority, got %s", subtask2.Priority)
+ }
+ // Dependencies should be updated to account for the new agent creation subtask
+ if len(subtask2.Dependencies) != 1 || subtask2.Dependencies[0] != "1" {
+ t.Errorf("Expected dependencies [1] (updated for agent creation), got %v", subtask2.Dependencies)
+ }
+ if len(subtask2.RequiredSkills) != 2 {
+ t.Errorf("Expected 2 required skills, got %d", len(subtask2.RequiredSkills))
+ }
+
+ // Total hours should include agent creation time (4 hours)
+ if analysis.EstimatedTotalHours != 28 {
+ t.Errorf("Expected 28 total hours, got %d", analysis.EstimatedTotalHours)
+ }
+}
+
+func TestAnalyzeTaskForSubtasks_InvalidJSON(t *testing.T) {
+ invalidResponse := "This is not valid JSON"
+
+ mockProvider := NewMockLLMProvider([]string{invalidResponse})
+ agentRoles := []string{"backend", "frontend"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test task",
+ }
+
+ _, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
+ if err == nil {
+ t.Error("Expected error for invalid JSON, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "no JSON found") {
+ t.Errorf("Expected 'no JSON found' error, got: %v", err)
+ }
+}
+
+func TestAnalyzeTaskForSubtasks_InvalidAgentRole(t *testing.T) {
+ jsonResponse := `{
+ "analysis_summary": "Test analysis",
+ "subtasks": [
+ {
+ "title": "Invalid Assignment",
+ "description": "Test subtask",
+ "priority": "high",
+ "assigned_to": "invalid_role",
+ "estimated_hours": 8,
+ "dependencies": []
+ }
+ ],
+ "recommended_approach": "Test approach",
+ "estimated_total_hours": 8
+}`
+
+ mockProvider := NewMockLLMProvider([]string{jsonResponse})
+ agentRoles := []string{"backend", "frontend"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test task",
+ }
+
+ analysis, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
+ if err != nil {
+ t.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
+ }
+
+ // Should fix invalid agent assignment to first available role
+ if analysis.Subtasks[0].AssignedTo != "backend" {
+ t.Errorf("Expected fixed assignment 'backend', got %s", analysis.Subtasks[0].AssignedTo)
+ }
+}
+
+func TestGenerateSubtaskPR(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
+
+ analysis := &tm.SubtaskAnalysis{
+ ParentTaskID: "task-123",
+ AnalysisSummary: "Test analysis summary",
+ RecommendedApproach: "Test approach",
+ EstimatedTotalHours: 40,
+ RiskAssessment: "Low risk",
+ Subtasks: []tm.SubtaskProposal{
+ {
+ Title: "Test Subtask",
+ Description: "Test description",
+ Priority: tm.PriorityHigh,
+ AssignedTo: "backend",
+ EstimatedHours: 8,
+ Dependencies: []string{},
+ },
+ },
+ }
+
+ // Test that PR generation fails when no PR provider is configured
+ _, err := service.GenerateSubtaskPR(context.Background(), analysis)
+ if err == nil {
+ t.Error("Expected error when PR provider not configured, got nil")
+ }
+
+ if !strings.Contains(err.Error(), "PR provider not configured") {
+ t.Errorf("Expected 'PR provider not configured' error, got: %v", err)
+ }
+}
+
+func TestBuildSubtaskAnalysisPrompt(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ agentRoles := []string{"backend", "frontend", "qa"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ task := &tm.Task{
+ Title: "Build authentication system",
+ Description: "Implement user login and registration with OAuth",
+ Priority: tm.PriorityHigh,
+ Status: tm.StatusToDo,
+ }
+
+ prompt := service.buildSubtaskAnalysisPrompt(task)
+
+ if !strings.Contains(prompt, task.Title) {
+ t.Error("Prompt should contain task title")
+ }
+
+ if !strings.Contains(prompt, task.Description) {
+ t.Error("Prompt should contain task description")
+ }
+
+ if !strings.Contains(prompt, string(task.Priority)) {
+ t.Error("Prompt should contain task priority")
+ }
+
+ if !strings.Contains(prompt, string(task.Status)) {
+ t.Error("Prompt should contain task status")
+ }
+}
+
+func TestGetSubtaskAnalysisSystemPrompt(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ agentRoles := []string{"backend", "frontend", "qa", "devops"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ systemPrompt := service.getSubtaskAnalysisSystemPrompt()
+
+ if !strings.Contains(systemPrompt, "backend") {
+ t.Error("System prompt should contain backend role")
+ }
+
+ if !strings.Contains(systemPrompt, "frontend") {
+ t.Error("System prompt should contain frontend role")
+ }
+
+ if !strings.Contains(systemPrompt, "JSON") {
+ t.Error("System prompt should mention JSON format")
+ }
+
+ if !strings.Contains(systemPrompt, "subtasks") {
+ t.Error("System prompt should mention subtasks")
+ }
+}
+
+func TestIsValidAgentRole(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ agentRoles := []string{"backend", "frontend", "qa"}
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
+
+ if !service.isValidAgentRole("backend") {
+ t.Error("'backend' should be a valid agent role")
+ }
+
+ if !service.isValidAgentRole("frontend") {
+ t.Error("'frontend' should be a valid agent role")
+ }
+
+ if service.isValidAgentRole("invalid") {
+ t.Error("'invalid' should not be a valid agent role")
+ }
+
+ if service.isValidAgentRole("") {
+ t.Error("Empty string should not be a valid agent role")
+ }
+}
+
+func TestParseSubtaskAnalysis_Priority(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
+
+ tests := []struct {
+ input string
+ expected tm.TaskPriority
+ }{
+ {"high", tm.PriorityHigh},
+ {"HIGH", tm.PriorityHigh},
+ {"High", tm.PriorityHigh},
+ {"low", tm.PriorityLow},
+ {"LOW", tm.PriorityLow},
+ {"Low", tm.PriorityLow},
+ {"medium", tm.PriorityMedium},
+ {"MEDIUM", tm.PriorityMedium},
+ {"Medium", tm.PriorityMedium},
+ {"invalid", tm.PriorityMedium}, // default
+ {"", tm.PriorityMedium}, // default
+ }
+
+ for _, test := range tests {
+ jsonResponse := `{
+ "analysis_summary": "Test",
+ "subtasks": [{
+ "title": "Test",
+ "description": "Test",
+ "priority": "` + test.input + `",
+ "assigned_to": "backend",
+ "estimated_hours": 8,
+ "dependencies": []
+ }],
+ "recommended_approach": "Test",
+ "estimated_total_hours": 8
+}`
+
+ analysis, err := service.parseSubtaskAnalysis(jsonResponse, "test-task")
+ if err != nil {
+ t.Fatalf("parseSubtaskAnalysis failed for priority '%s': %v", test.input, err)
+ }
+
+ if len(analysis.Subtasks) != 1 {
+ t.Fatalf("Expected 1 subtask, got %d", len(analysis.Subtasks))
+ }
+
+ if analysis.Subtasks[0].Priority != test.expected {
+ t.Errorf("For priority '%s', expected %s, got %s",
+ test.input, test.expected, analysis.Subtasks[0].Priority)
+ }
+ }
+}
+
+func TestGenerateSubtaskFile(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
+
+ subtask := tm.SubtaskProposal{
+ Title: "Implement API endpoints",
+ Description: "Create REST API endpoints for user management",
+ Priority: tm.PriorityHigh,
+ AssignedTo: "backend",
+ EstimatedHours: 12,
+ Dependencies: []string{"0"},
+ RequiredSkills: []string{"go", "rest_api"},
+ }
+
+ taskID := "parent-task-subtask-1"
+ parentTaskID := "parent-task"
+
+ content := service.generateSubtaskFile(subtask, taskID, parentTaskID)
+
+ // Verify YAML frontmatter
+ if !strings.Contains(content, "id: parent-task-subtask-1") {
+ t.Error("Generated file should contain task ID in frontmatter")
+ }
+ if !strings.Contains(content, "title: Implement API endpoints") {
+ t.Error("Generated file should contain task title in frontmatter")
+ }
+ if !strings.Contains(content, "assignee: backend") {
+ t.Error("Generated file should contain assignee in frontmatter")
+ }
+ if !strings.Contains(content, "status: todo") {
+ t.Error("Generated file should have 'todo' status")
+ }
+ if !strings.Contains(content, "priority: high") {
+ t.Error("Generated file should contain priority in frontmatter")
+ }
+ if !strings.Contains(content, "parent_task_id: parent-task") {
+ t.Error("Generated file should contain parent task ID")
+ }
+ if !strings.Contains(content, "estimated_hours: 12") {
+ t.Error("Generated file should contain estimated hours")
+ }
+
+ // Verify dependencies are converted properly
+ if !strings.Contains(content, "dependencies:") {
+ t.Error("Generated file should contain dependencies section")
+ }
+ if !strings.Contains(content, "- parent-task-subtask-1") {
+ t.Error("Dependencies should be converted to subtask IDs")
+ }
+
+ // Verify required skills
+ if !strings.Contains(content, "required_skills:") {
+ t.Error("Generated file should contain required skills section")
+ }
+ if !strings.Contains(content, "- go") {
+ t.Error("Generated file should contain required skills")
+ }
+
+ // Verify markdown content
+ if !strings.Contains(content, "# Task Description") {
+ t.Error("Generated file should contain markdown task description")
+ }
+ if !strings.Contains(content, "Create REST API endpoints for user management") {
+ t.Error("Generated file should contain task description in body")
+ }
+}
+
+func TestUpdateParentTaskAsCompleted(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
+
+ // Create a temporary task file for testing
+ taskContent := `---
+id: test-task-123
+title: Test Task
+description: A test task for validation
+assignee: backend
+status: todo
+priority: high
+created_at: 2024-01-01T10:00:00Z
+updated_at: 2024-01-01T10:00:00Z
+completed_at: null
+---
+
+# Task Description
+
+A test task for validation
+`
+
+ // Create temporary file
+ tmpFile, err := os.CreateTemp("", "test-task-*.md")
+ if err != nil {
+ t.Fatalf("Failed to create temp file: %v", err)
+ }
+ defer os.Remove(tmpFile.Name())
+
+ if err := os.WriteFile(tmpFile.Name(), []byte(taskContent), 0644); err != nil {
+ t.Fatalf("Failed to write temp file: %v", err)
+ }
+
+ // Create analysis with subtasks
+ analysis := &tm.SubtaskAnalysis{
+ ParentTaskID: "test-task-123",
+ EstimatedTotalHours: 20,
+ Subtasks: []tm.SubtaskProposal{
+ {
+ Title: "Subtask 1",
+ AssignedTo: "backend",
+ },
+ {
+ Title: "Subtask 2",
+ AssignedTo: "frontend",
+ },
+ },
+ }
+
+ // Update the parent task
+ err = service.updateParentTaskAsCompleted(tmpFile.Name(), analysis)
+ if err != nil {
+ t.Fatalf("updateParentTaskAsCompleted failed: %v", err)
+ }
+
+ // Read the updated file
+ updatedContent, err := os.ReadFile(tmpFile.Name())
+ if err != nil {
+ t.Fatalf("Failed to read updated file: %v", err)
+ }
+
+ updatedString := string(updatedContent)
+
+ // Verify the status was changed to completed
+ if !strings.Contains(updatedString, "status: completed") {
+ t.Error("Updated file should contain 'status: completed'")
+ }
+
+ // Verify completed_at was set (should not be null)
+ if strings.Contains(updatedString, "completed_at: null") {
+ t.Error("Updated file should have completed_at timestamp, not null")
+ }
+
+ // Verify subtask information was added
+ if !strings.Contains(updatedString, "## Subtasks Created") {
+ t.Error("Updated file should contain subtasks information")
+ }
+
+ if !strings.Contains(updatedString, "test-task-123-subtask-1") {
+ t.Error("Updated file should reference created subtask IDs")
+ }
+
+ if !strings.Contains(updatedString, "**Total Estimated Hours:** 20") {
+ t.Error("Updated file should contain total estimated hours")
+ }
+}
+
+func TestClose(t *testing.T) {
+ mockProvider := NewMockLLMProvider([]string{})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
+
+ err := service.Close()
+ if err != nil {
+ t.Errorf("Close should not return error, got: %v", err)
+ }
+}
+
+// Benchmark tests
+func BenchmarkAnalyzeTaskForSubtasks(b *testing.B) {
+ jsonResponse := `{
+ "analysis_summary": "Benchmark test",
+ "subtasks": [
+ {
+ "title": "Benchmark Subtask",
+ "description": "Benchmark description",
+ "priority": "high",
+ "assigned_to": "backend",
+ "estimated_hours": 8,
+ "dependencies": []
+ }
+ ],
+ "recommended_approach": "Benchmark approach",
+ "estimated_total_hours": 8
+}`
+
+ mockProvider := NewMockLLMProvider([]string{jsonResponse})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend", "frontend"}, nil, "example", "repo", nil, nil)
+
+ task := &tm.Task{
+ ID: "benchmark-task",
+ Title: "Benchmark Task",
+ Description: "Task for benchmarking",
+ Priority: tm.PriorityHigh,
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ // Reset mock provider for each iteration
+ mockProvider.callCount = 0
+ _, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
+ if err != nil {
+ b.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
+ }
+ }
+}