fd79d6170f
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task Extends multi-repo into multi-branch so a single task can hold multiple worktrees on the same repo (one per branch). Sibling worktrees layout at <task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch, live agentctl tracker rescan + frontend store updates so the UI splits PRs / files / commits per branch without a session restart. MCP add_branch_to_task_kandev tool is shielded to the worktree executor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): unify multi-branch repo label as <repo> · <branch> across panels PR Changes and Commits panels rendered repo labels differently for multi-branch tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw "kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't collapse to kandev · cli-*. * fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes Backend - agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds trackers first, takes Lock for swap/append. Validates newWorkDir via os.Stat before publishing cfg.WorkDir. - worktree.Manager: reject invalid explicit BranchSlug up-front; cache key uses sanitized wt.BranchSlug so read/write paths are symmetric. - worktree.store: narrow isDuplicateColumnError to "duplicate column name". - task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a. - orchestrator resolvePushRepo: two-pass exact-name match before subdir prefix fallback so "backend" doesn't swallow pushes for "backend-admin". - executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug (falls back to branch-N when both branch fields sanitize to empty). - MCP handler: classifyAddBranchError maps service errors to NOT_FOUND / CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant. - MCP server: extract registerAddBranchToTaskTool, register in task mode only (external mode contract: config tools + create_task_kandev). - workspace_rescan handler: reject malformed JSON with 400 instead of silently rescanning the current root. - branch_materializer: split MaterializeBranch into prepare + finalize helpers to satisfy the 80-line cap. Frontend - existingPrUrl returns undefined when a task has >1 PR so per-repo View PR buttons take over (no silent linking to a sibling). - formatRepoLabel checks exact known-repo match before prefix splitting so "kandev-cli" isn't mislabeled as "kandev · cli". - Repo chip key now includes base_branch so siblings differing only on base render as distinct chips. Docs - multi-branch spec uniqueness wording aligned with the canonical 4-column key (task_id, repository_id, base_branch, checkout_branch). - worktree.CreateRequest.BranchSlug doc describes the sibling layout (<repo>-<slug>) instead of the obsolete nested form. * fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound - service_branches: drop base_branch from the slug-collision check. Worktree path is (repo, slug); two rows on the same repo cut from different bases still collide on disk. - service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via fmt.Errorf with %w so classifiers don't have to substring-match the formatted task UUID. - mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound). * fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection CodeQL flagged the rescan endpoint as a path-injection sink (the HTTP-supplied work_dir flowed straight into os.Stat and downstream scanRepositorySubdirs). Add validateRescanPath which: - requires the input to be a clean absolute path with no ".." segments - rejects anything that isn't equal to or an ancestor of the current cfg.WorkDir — the only legitimate use case is the branch materializer promoting <task>/<repo>/ to <task>/ Defense in depth: agentctl binds to localhost, but any colocated process could otherwise direct it at /etc or the user's home by POSTing /api/v1/workspace/rescan. * fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests - Drop the strings.Contains(clean, "..") guard. filepath.Clean already resolves traversal; the substring check rejects legitimate names like /home/u/..hidden without adding defense (the ancestor check via filepath.Rel is the real boundary). - Drop the rel == "." branch in the ancestor check — early return for clean == currentClean already covers identity. - Read existingTrackers inside the write-lock section (not under RLock then re-Lock) so two concurrent rescans can't both see 0 trackers and double-bootstrap the multi-repo set. - Add table-driven TestValidateRescanPath covering rejection paths (relative, descendant, sibling, traversal-cleaned, empty input), legitimate ancestor promotion, exact match, no-anchor first-launch, and a /home/u/..hidden dotted-segment case. * fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body CodeQL's go/path-injection trace still flagged manager_rescan.go after the prior sanitizer because the user-supplied newWorkDir flowed (after validation) into os.Stat and then into cfg.WorkDir → tracker paths → workspace_monitor os.Stat sinks. Replace validateRescanPath with resolveRescanPath: it returns a path DERIVED from currentWorkDir (no-op = currentClean, promotion = filepath.Dir(currentClean)) and uses newPath only for comparison, never as a source for the returned string. os.Stat now receives a path whose provenance is trusted manager config. Functionally the legitimate caller (branch materializer promoting <task>/<repo>/ to <task>/) is unchanged. * test(agentctl): make TestResolveRescanPath windows-portable The Linux Backend Tests job passed but the Backend (windows) job rejected every "absolute" case because filepath.IsAbs("/task/repo") is false on Windows (no volume letter). Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" / "\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`) without per-OS branches. Keep the traversal-after-Clean case POSIX-only since filepath.Clean is the shared primitive and that input form needs a volume to be considered absolute on Windows anyway. * fix(agentctl): serialize RescanRepositories + re-check trackers under write lock Two concurrent RescanRepositories calls could both observe an empty tracker set (between the snapshot read and the bootstrap branch) and both enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker and duplicate per-repo trackers. Same race in appendNewRepoTrackers would double-append the same new child across both callers. Fix: - Add rescanMu sync.Mutex on Manager. Acquired for the full body of RescanRepositories so the snapshot → branch → mutate sequence is serialized. repoTrackersMu still guards individual field reads from unrelated goroutines (subscribe/unsubscribe/poll-mode). - appendNewRepoTrackers re-checks membership inside the write-lock before appending and drops already-present trackers (Stop + detach outside the lock). Defense in depth. - Test: add `dotted segment is legal as promotion` case so the ..hidden-segment promotion path stays exercised. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1103 lines
38 KiB
Go
1103 lines
38 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/gitref"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
)
|
|
|
|
// defaultPriority is the default value for the task priority column.
|
|
// Used when a caller omits priority so the DB CHECK constraint is satisfied.
|
|
const defaultPriority = "medium"
|
|
|
|
// ErrSubtaskDepthExceeded is returned when a caller tries to create a
|
|
// subtask of a kanban subtask (nesting depth > 1). Office task trees are
|
|
// intentionally exempt.
|
|
var ErrSubtaskDepthExceeded = fmt.Errorf("cannot create a subtask of a subtask — maximum nesting depth is 1 for kanban tasks. Create a sibling task under the same parent or a top-level task instead")
|
|
|
|
type taskStopTarget struct {
|
|
sessionID string
|
|
executionID string
|
|
}
|
|
|
|
type taskEnvironmentCleanup struct {
|
|
env *models.TaskEnvironment
|
|
deleteRow bool
|
|
}
|
|
|
|
// Task operations
|
|
|
|
// isOfficeRequest returns true if the request should create an office task.
|
|
func isOfficeRequest(req *CreateTaskRequest) bool {
|
|
return req.ProjectID != "" ||
|
|
req.Origin == models.TaskOriginAgentCreated ||
|
|
req.Origin == models.TaskOriginRoutine ||
|
|
req.Origin == models.TaskOriginOnboarding
|
|
}
|
|
|
|
// CreateTask creates a new task and publishes a task.created event.
|
|
// WorkflowID is required for non-ephemeral kanban tasks.
|
|
// Office tasks (project_id set, or origin is agent_created/routine)
|
|
// auto-resolve to the workspace's office workflow.
|
|
// Ephemeral tasks (quick chat, config chat) must NOT have a workflow.
|
|
func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) {
|
|
if err := s.validateCreateTaskRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validateSubtaskDepth(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Subtasks created without explicit repositories inherit the parent's, so
|
|
// an inherit_parent subtask resolves a repo at launch and can reuse the
|
|
// parent's worktree (the UI omits repositories expecting this). Mirrors the
|
|
// MCP create_task path so UI- and agent-created subtasks behave identically.
|
|
if err := s.inheritParentRepositories(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// For office tasks, resolve workflow from workspace
|
|
if isOfficeRequest(req) && req.WorkflowID == "" {
|
|
if err := s.resolveOfficeWorkflow(ctx, req); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
workflowStepID := s.resolveWorkflowStep(ctx, req)
|
|
task := s.buildTask(req, workflowStepID)
|
|
|
|
// Auto-assign identifier for office tasks
|
|
if isOfficeRequest(req) {
|
|
if err := s.assignIdentifier(ctx, task); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := s.tasks.CreateTask(ctx, task); err != nil {
|
|
s.logger.Error("failed to create task", zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Create blocker relationships if specified.
|
|
for _, blockerID := range req.BlockedBy {
|
|
if err := s.AddBlocker(ctx, task.ID, blockerID); err != nil {
|
|
return nil, fmt.Errorf("add blocker %s: %w", blockerID, err)
|
|
}
|
|
}
|
|
|
|
if err := s.createTaskRepositories(ctx, task.ID, req.WorkspaceID, req.Repositories); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load repositories into task for response
|
|
repos, err := s.taskRepos.ListTaskRepositories(ctx, task.ID)
|
|
if err != nil {
|
|
s.logger.Error("failed to list task repositories", zap.Error(err))
|
|
} else {
|
|
task.Repositories = repos
|
|
}
|
|
|
|
s.publishTaskEvent(ctx, events.TaskCreated, task, nil)
|
|
s.logger.Info("task created", zap.String("task_id", task.ID), zap.String("title", task.Title))
|
|
|
|
return task, nil
|
|
}
|
|
|
|
// inheritParentRepositories fills req.Repositories from the parent task when a
|
|
// subtask is created without explicit repositories. This applies to any
|
|
// repo-less subtask (not only inherit_parent ones), matching the MCP
|
|
// create_task path (mcp/handlers.inheritedRepoInputs) so UI- and agent-created
|
|
// subtasks behave identically — the UI's new_workspace mode always sends repos,
|
|
// so in practice only inherit_parent reaches here empty. RepositoryID and
|
|
// BaseBranch carry over; CheckoutBranch is dropped on purpose because two
|
|
// worktrees can't share a working branch, so the subtask branches off the same
|
|
// base as the parent.
|
|
//
|
|
// A lookup failure is returned rather than swallowed: a subtask silently
|
|
// created with no repositories can't establish a worktree, which would
|
|
// reintroduce the exact fresh-worktree bug this inheritance is meant to fix —
|
|
// failing fast surfaces the problem at creation time instead.
|
|
func (s *Service) inheritParentRepositories(ctx context.Context, req *CreateTaskRequest) error {
|
|
if req.ParentID == "" || len(req.Repositories) > 0 {
|
|
return nil
|
|
}
|
|
parentRepos, err := s.taskRepos.ListTaskRepositories(ctx, req.ParentID)
|
|
if err != nil {
|
|
return fmt.Errorf("list parent repositories for subtask inheritance: %w", err)
|
|
}
|
|
inherited := make([]TaskRepositoryInput, 0, len(parentRepos))
|
|
for _, r := range parentRepos {
|
|
if r == nil || r.RepositoryID == "" {
|
|
continue
|
|
}
|
|
inherited = append(inherited, TaskRepositoryInput{
|
|
RepositoryID: r.RepositoryID,
|
|
BaseBranch: r.BaseBranch,
|
|
})
|
|
}
|
|
if len(inherited) > 0 {
|
|
req.Repositories = inherited
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateCreateTaskRequest validates constraints for task creation.
|
|
func (s *Service) validateCreateTaskRequest(req *CreateTaskRequest) error {
|
|
isOffice := isOfficeRequest(req)
|
|
if !req.IsEphemeral && !isOffice && req.WorkflowID == "" {
|
|
return fmt.Errorf("workflow_id is required for non-ephemeral tasks")
|
|
}
|
|
if req.IsEphemeral && req.WorkflowID != "" {
|
|
return fmt.Errorf("workflow_id must be empty for ephemeral tasks")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateSubtaskDepth prevents nesting deeper than one level for kanban
|
|
// (non-office) tasks. Office task trees intentionally allow arbitrary depth.
|
|
func (s *Service) validateSubtaskDepth(ctx context.Context, req *CreateTaskRequest) error {
|
|
if req.ParentID == "" {
|
|
return nil
|
|
}
|
|
parent, err := s.tasks.GetTask(ctx, req.ParentID)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid parent_id: %w", err)
|
|
}
|
|
if parent.ParentID != "" && !parent.IsFromOffice {
|
|
return ErrSubtaskDepthExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveOfficeWorkflow sets WorkflowID on the request from the workspace's office workflow.
|
|
func (s *Service) resolveOfficeWorkflow(ctx context.Context, req *CreateTaskRequest) error {
|
|
_, orchWorkflowID, err := s.tasks.GetWorkspaceTaskPrefix(ctx, req.WorkspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get office workflow for workspace: %w", err)
|
|
}
|
|
if orchWorkflowID == "" {
|
|
return fmt.Errorf("workspace %s has no office workflow configured", req.WorkspaceID)
|
|
}
|
|
req.WorkflowID = orchWorkflowID
|
|
return nil
|
|
}
|
|
|
|
// resolveWorkflowStep resolves the starting workflow step for a new task.
|
|
func (s *Service) resolveWorkflowStep(ctx context.Context, req *CreateTaskRequest) string {
|
|
workflowStepID := req.WorkflowStepID
|
|
if workflowStepID == "" && req.WorkflowID != "" && s.startStepResolver != nil {
|
|
var resolvedID string
|
|
var err error
|
|
if req.PlanMode {
|
|
resolvedID, err = s.startStepResolver.ResolveFirstStep(ctx, req.WorkflowID)
|
|
} else {
|
|
resolvedID, err = s.startStepResolver.ResolveStartStep(ctx, req.WorkflowID)
|
|
}
|
|
if err != nil {
|
|
s.logger.Warn("failed to resolve start step, using empty",
|
|
zap.String("workflow_id", req.WorkflowID),
|
|
zap.Error(err))
|
|
} else {
|
|
workflowStepID = resolvedID
|
|
}
|
|
}
|
|
return workflowStepID
|
|
}
|
|
|
|
// buildTask constructs a Task model from the CreateTaskRequest.
|
|
func (s *Service) buildTask(req *CreateTaskRequest, workflowStepID string) *models.Task {
|
|
state := v1.TaskStateCreated
|
|
if req.State != nil {
|
|
state = *req.State
|
|
}
|
|
origin := req.Origin
|
|
if origin == "" {
|
|
origin = models.TaskOriginManual
|
|
}
|
|
labels := req.Labels
|
|
if labels == "" {
|
|
labels = "[]"
|
|
}
|
|
priority := req.Priority
|
|
if priority == "" {
|
|
// Office tasks have a TEXT priority column with a CHECK constraint
|
|
// against the canonical four-value enum; default empty to defaultPriority
|
|
// so callers (e.g. onboarding) can omit it.
|
|
priority = defaultPriority
|
|
}
|
|
metadata := req.Metadata
|
|
if wsPath := strings.TrimSpace(req.WorkspacePath); wsPath != "" {
|
|
if metadata == nil {
|
|
metadata = make(map[string]interface{})
|
|
}
|
|
metadata[models.MetaKeyWorkspacePath] = wsPath
|
|
}
|
|
return &models.Task{
|
|
ID: uuid.New().String(),
|
|
WorkspaceID: req.WorkspaceID,
|
|
WorkflowID: req.WorkflowID,
|
|
WorkflowStepID: workflowStepID,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
State: state,
|
|
Priority: priority,
|
|
Position: req.Position,
|
|
Metadata: metadata,
|
|
IsEphemeral: req.IsEphemeral,
|
|
ParentID: req.ParentID,
|
|
AssigneeAgentProfileID: req.AssigneeAgentProfileID,
|
|
Origin: origin,
|
|
ProjectID: req.ProjectID,
|
|
Labels: labels,
|
|
}
|
|
}
|
|
|
|
// assignIdentifier generates a sequential identifier (e.g. "KAN-1") for the task.
|
|
func (s *Service) assignIdentifier(ctx context.Context, task *models.Task) error {
|
|
prefix, _, err := s.tasks.GetWorkspaceTaskPrefix(ctx, task.WorkspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get task prefix: %w", err)
|
|
}
|
|
seq, err := s.tasks.IncrementTaskSequence(ctx, task.WorkspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to increment task sequence: %w", err)
|
|
}
|
|
task.Identifier = fmt.Sprintf("%s-%d", prefix, seq)
|
|
return nil
|
|
}
|
|
|
|
// createTaskRepositories creates task-repository associations, resolving local paths to repository IDs.
|
|
func (s *Service) createTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
|
|
var repoByPath map[string]*models.Repository
|
|
for _, repoInput := range repositories {
|
|
if repoInput.RepositoryID == "" && repoInput.LocalPath != "" {
|
|
repos, err := s.repoEntities.ListRepositories(ctx, workspaceID)
|
|
if err != nil {
|
|
s.logger.Error("failed to list repositories", zap.Error(err))
|
|
return err
|
|
}
|
|
repoByPath = make(map[string]*models.Repository, len(repos))
|
|
for _, repo := range repos {
|
|
if repo.LocalPath == "" {
|
|
continue
|
|
}
|
|
repoByPath[repo.LocalPath] = repo
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]bool, len(repositories))
|
|
for i, repoInput := range repositories {
|
|
repositoryID, baseBranch, err := s.resolveRepoInput(ctx, workspaceID, repoInput, repoByPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if repositoryID == "" {
|
|
return fmt.Errorf("repository_id is required")
|
|
}
|
|
// Multi-branch validation: the same repository may appear multiple
|
|
// times in a task on different branches. Identity is
|
|
// (repository_id, base_branch, checkout_branch) — base_branch matters
|
|
// because the worktree executor anchors the branch there while
|
|
// checkout_branch stays empty, and the local-executor flow puts the
|
|
// branch in checkout_branch with base_branch anchored to default_branch.
|
|
// Both shapes must dedup; matching DB key is UNIQUE(task_id,
|
|
// repository_id, base_branch, checkout_branch).
|
|
dedupKey := repositoryID + "\x00" + baseBranch + "\x00" + repoInput.CheckoutBranch
|
|
if seen[dedupKey] {
|
|
label := s.repoDisplayLabel(ctx, repoInput, repositoryID)
|
|
branchLabel := repoInput.CheckoutBranch
|
|
if branchLabel == "" {
|
|
branchLabel = baseBranch
|
|
}
|
|
if branchLabel == "" {
|
|
return fmt.Errorf("repository %q is listed more than once for this task", label)
|
|
}
|
|
return fmt.Errorf("repository %q on branch %q is listed more than once for this task", label, branchLabel)
|
|
}
|
|
seen[dedupKey] = true
|
|
metadata := make(map[string]interface{})
|
|
if prNum := resolvePRNumber(repoInput); prNum > 0 {
|
|
metadata["pr_number"] = prNum
|
|
}
|
|
taskRepo := &models.TaskRepository{
|
|
TaskID: taskID,
|
|
RepositoryID: repositoryID,
|
|
BaseBranch: baseBranch,
|
|
CheckoutBranch: repoInput.CheckoutBranch,
|
|
Position: i,
|
|
Metadata: metadata,
|
|
}
|
|
if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil {
|
|
s.logger.Error("failed to create task repository", zap.Error(err))
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// repoDisplayLabel returns a human-readable label for a repository to surface
|
|
// in the duplicate-repository error. It prefers owner/name parsed from the
|
|
// input's GitHub URL, then the resolved repo entity's owner/name (or bare
|
|
// name), and finally falls back to the repositoryID so the message is never
|
|
// empty. Best-effort: lookup failures degrade to the next fallback.
|
|
func (s *Service) repoDisplayLabel(ctx context.Context, repoInput TaskRepositoryInput, repositoryID string) string {
|
|
if repoInput.GitHubURL != "" {
|
|
if owner, name, err := parseGitHubRepoURL(repoInput.GitHubURL); err == nil {
|
|
return owner + "/" + name
|
|
}
|
|
}
|
|
if repo, err := s.repoEntities.GetRepository(ctx, repositoryID); err == nil && repo != nil {
|
|
if repo.ProviderOwner != "" && repo.ProviderName != "" {
|
|
return repo.ProviderOwner + "/" + repo.ProviderName
|
|
}
|
|
if repo.Name != "" {
|
|
return repo.Name
|
|
}
|
|
}
|
|
return repositoryID
|
|
}
|
|
|
|
// resolveRepoInput resolves a RepositoryInput to a repositoryID and baseBranch,
|
|
// creating the repository if it doesn't exist yet.
|
|
func (s *Service) resolveRepoInput(ctx context.Context, workspaceID string, repoInput TaskRepositoryInput, repoByPath map[string]*models.Repository) (repositoryID, baseBranch string, err error) {
|
|
repositoryID = repoInput.RepositoryID
|
|
baseBranch = repoInput.BaseBranch
|
|
if repositoryID != "" {
|
|
// Verify the repository belongs to the target workspace. Without this
|
|
// check, an agent that knows a repository UUID from another workspace
|
|
// could associate it with a task in this workspace via the MCP tool's
|
|
// repository_id fast path (the github_url and local_path branches both
|
|
// scope through FindOrCreateRepository, which is workspace-bound).
|
|
repo, lookupErr := s.repoEntities.GetRepository(ctx, repositoryID)
|
|
if lookupErr != nil {
|
|
return "", "", fmt.Errorf("looking up repository %q: %w", repositoryID, lookupErr)
|
|
}
|
|
if repo == nil || repo.WorkspaceID != workspaceID {
|
|
return "", "", fmt.Errorf("repository %q does not belong to workspace %q", repositoryID, workspaceID)
|
|
}
|
|
return repositoryID, baseBranch, nil
|
|
}
|
|
|
|
// Handle GitHub URL: parse owner/name and use FindOrCreateRepository
|
|
if repoInput.GitHubURL != "" {
|
|
owner, name, parseErr := parseGitHubRepoURL(repoInput.GitHubURL)
|
|
if parseErr != nil {
|
|
return "", "", parseErr
|
|
}
|
|
defaultBranch := repoInput.DefaultBranch
|
|
if defaultBranch == "" {
|
|
defaultBranch = repoInput.BaseBranch
|
|
}
|
|
repo, createErr := s.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{
|
|
WorkspaceID: workspaceID,
|
|
Provider: "github",
|
|
ProviderOwner: owner,
|
|
ProviderName: name,
|
|
DefaultBranch: defaultBranch,
|
|
})
|
|
if createErr != nil {
|
|
return "", "", createErr
|
|
}
|
|
repositoryID = repo.ID
|
|
if baseBranch == "" {
|
|
baseBranch = repo.DefaultBranch
|
|
}
|
|
return repositoryID, baseBranch, nil
|
|
}
|
|
|
|
if repoInput.LocalPath == "" {
|
|
return repositoryID, baseBranch, nil
|
|
}
|
|
repo := repoByPath[repoInput.LocalPath]
|
|
if repo == nil {
|
|
name := strings.TrimSpace(repoInput.Name)
|
|
if name == "" {
|
|
name = filepath.Base(repoInput.LocalPath)
|
|
}
|
|
// Resolve default_branch by probing the repo on disk so it's anchored
|
|
// to the integration branch (origin/HEAD or main/master) rather than
|
|
// whatever feature branch the user happens to have checked out. The
|
|
// frontend's `default_branch` hint wins when set; otherwise we probe
|
|
// directly. Falling back to repoInput.BaseBranch is wrong because in
|
|
// the local-executor flow that field carries the user's working
|
|
// branch, which would permanently pin repositories.default_branch to
|
|
// a feature branch and break every downstream merge-base lookup.
|
|
defaultBranch := repoInput.DefaultBranch
|
|
if defaultBranch == "" {
|
|
// Probe must operate on a path validated against the discovery
|
|
// allowlist — repoInput.LocalPath comes straight from the HTTP body
|
|
// and feeds into os.Stat/ReadFile inside gitref.DefaultBranch, so
|
|
// without this guard a caller could traverse the filesystem.
|
|
if safePath, pathErr := s.resolveAllowedLocalPath(repoInput.LocalPath); pathErr == nil {
|
|
if probed, err := gitref.DefaultBranch(safePath); err == nil && probed != "" && probed != "HEAD" {
|
|
defaultBranch = probed
|
|
}
|
|
}
|
|
}
|
|
created, createErr := s.CreateRepository(ctx, &CreateRepositoryRequest{
|
|
WorkspaceID: workspaceID,
|
|
Name: name,
|
|
SourceType: "local",
|
|
LocalPath: repoInput.LocalPath,
|
|
DefaultBranch: defaultBranch,
|
|
})
|
|
if createErr != nil {
|
|
return "", "", createErr
|
|
}
|
|
repo = created
|
|
if repoByPath != nil {
|
|
repoByPath[repoInput.LocalPath] = repo
|
|
}
|
|
}
|
|
repositoryID = repo.ID
|
|
if baseBranch == "" {
|
|
baseBranch = repo.DefaultBranch
|
|
}
|
|
return repositoryID, baseBranch, nil
|
|
}
|
|
|
|
// parseGitHubRepoURL parses a GitHub repository URL into owner and name.
|
|
// Supports: https://github.com/owner/repo, github.com/owner/repo,
|
|
// https://github.com/owner/repo.git, with optional trailing slashes.
|
|
func parseGitHubRepoURL(rawURL string) (owner, name string, err error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return "", "", fmt.Errorf("empty GitHub URL")
|
|
}
|
|
|
|
// Add scheme if missing so url.Parse works correctly
|
|
if !strings.Contains(rawURL, "://") {
|
|
rawURL = "https://" + rawURL
|
|
}
|
|
|
|
parsed, parseErr := url.Parse(rawURL)
|
|
if parseErr != nil {
|
|
return "", "", fmt.Errorf("invalid GitHub URL: %w", parseErr)
|
|
}
|
|
|
|
if parsed.Host != "github.com" && parsed.Host != "www.github.com" {
|
|
return "", "", fmt.Errorf("not a GitHub URL: %s", parsed.Host)
|
|
}
|
|
|
|
// Path should be /owner/name (possibly with .git suffix and trailing slash)
|
|
path := strings.Trim(parsed.Path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", fmt.Errorf("invalid GitHub repository URL: expected github.com/owner/repo")
|
|
}
|
|
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
// resolvePRNumber returns the GitHub PR number for a repository input. Prefers
|
|
// the explicit PRNumber field; falls back to parsing a /pull/<N> path out of
|
|
// GitHubURL when present. Returns 0 when no PR is identified.
|
|
//
|
|
// The PR number is needed at worktree-creation time so fork PRs (whose head
|
|
// branch only exists on the contributor's fork) can be materialized via the
|
|
// refs/pull/<N>/head refspec on the base repo instead of a branch-name fetch
|
|
// that would 404 against origin.
|
|
func resolvePRNumber(input TaskRepositoryInput) int {
|
|
if input.PRNumber > 0 {
|
|
return input.PRNumber
|
|
}
|
|
rawURL := strings.TrimSpace(input.GitHubURL)
|
|
idx := strings.Index(rawURL, "/pull/")
|
|
if idx < 0 {
|
|
return 0
|
|
}
|
|
numStr := rawURL[idx+len("/pull/"):]
|
|
if i := strings.IndexAny(numStr, "/?#"); i >= 0 {
|
|
numStr = numStr[:i]
|
|
}
|
|
n, err := strconv.Atoi(numStr)
|
|
if err != nil || n <= 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ReplaceTaskRepositories deletes all existing task-repository associations
|
|
// and recreates them. Exported for callers that mutate repository inputs
|
|
// (e.g. the fresh-branch flow rewriting BaseBranch) after CreateTask has
|
|
// already persisted the original set.
|
|
func (s *Service) ReplaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
|
|
return s.replaceTaskRepositories(ctx, taskID, workspaceID, repositories)
|
|
}
|
|
|
|
// replaceTaskRepositories deletes all existing task-repository associations and recreates them.
|
|
func (s *Service) replaceTaskRepositories(ctx context.Context, taskID, workspaceID string, repositories []TaskRepositoryInput) error {
|
|
if err := s.taskRepos.DeleteTaskRepositoriesByTask(ctx, taskID); err != nil {
|
|
s.logger.Error("failed to delete task repositories", zap.Error(err))
|
|
return err
|
|
}
|
|
return s.createTaskRepositories(ctx, taskID, workspaceID, repositories)
|
|
}
|
|
|
|
// GetTask retrieves a task by ID and populates repositories
|
|
func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
|
task, err := s.tasks.GetTask(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load task repositories
|
|
repos, err := s.taskRepos.ListTaskRepositories(ctx, id)
|
|
if err != nil {
|
|
s.logger.Error("failed to list task repositories", zap.Error(err))
|
|
} else {
|
|
task.Repositories = repos
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
// UpdateTask updates an existing task and publishes a task.updated event
|
|
func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) {
|
|
task, err := s.tasks.GetTask(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var oldState *v1.TaskState
|
|
stateChanged := false
|
|
|
|
if req.Title != nil {
|
|
task.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
task.Description = *req.Description
|
|
}
|
|
if req.Priority != nil {
|
|
task.Priority = *req.Priority
|
|
}
|
|
if req.State != nil && task.State != *req.State {
|
|
current := task.State
|
|
oldState = ¤t
|
|
task.State = *req.State
|
|
stateChanged = true
|
|
}
|
|
if req.Position != nil {
|
|
task.Position = *req.Position
|
|
}
|
|
if req.Metadata != nil {
|
|
task.Metadata = req.Metadata
|
|
}
|
|
task.UpdatedAt = time.Now().UTC()
|
|
|
|
if err := s.tasks.UpdateTask(ctx, task); err != nil {
|
|
s.logger.Error("failed to update task", zap.String("task_id", id), zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
// Update task repositories if provided
|
|
if req.Repositories != nil {
|
|
if err := s.replaceTaskRepositories(ctx, task.ID, task.WorkspaceID, req.Repositories); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Load repositories into task for response
|
|
repos, err := s.taskRepos.ListTaskRepositories(ctx, task.ID)
|
|
if err != nil {
|
|
s.logger.Error("failed to list task repositories", zap.Error(err))
|
|
} else {
|
|
task.Repositories = repos
|
|
}
|
|
|
|
if stateChanged && oldState != nil {
|
|
s.publishTaskEvent(ctx, events.TaskStateChanged, task, oldState)
|
|
}
|
|
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
|
s.logger.Info("task updated", zap.String("task_id", task.ID))
|
|
|
|
return task, nil
|
|
}
|
|
|
|
// ArchiveTask archives a task by setting its archived_at timestamp.
|
|
// The task remains in the DB but is excluded from active board views.
|
|
// Active agent sessions are stopped and worktrees cleaned up in background.
|
|
func (s *Service) ArchiveTask(ctx context.Context, id string) error {
|
|
start := time.Now()
|
|
|
|
// 1. Get task and verify it exists
|
|
task, err := s.tasks.GetTask(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if task.ArchivedAt != nil {
|
|
return fmt.Errorf("task is already archived: %s", id)
|
|
}
|
|
|
|
// 2. Gather data needed for cleanup BEFORE archive
|
|
var stopTargets []taskStopTarget
|
|
activeSessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list active sessions for archive",
|
|
zap.String("task_id", id),
|
|
zap.Error(err))
|
|
}
|
|
if s.executionStopper != nil {
|
|
stopTargets = s.buildStopTargets(ctx, id, activeSessions)
|
|
}
|
|
|
|
// 2b. Capture git archive snapshot for active sessions BEFORE stopping agents
|
|
// Use a bounded timeout to prevent blocking the archive operation if agentctl is stuck.
|
|
if s.gitArchiveCapture != nil && len(activeSessions) > 0 {
|
|
for _, sess := range activeSessions {
|
|
if sess == nil || sess.ID == "" {
|
|
continue
|
|
}
|
|
snapCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := s.gitArchiveCapture.CaptureArchiveSnapshot(snapCtx, sess.ID)
|
|
cancel()
|
|
if err != nil {
|
|
s.logger.Warn("failed to capture git archive snapshot",
|
|
zap.String("task_id", id),
|
|
zap.String("session_id", sess.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
sessions, err := s.sessions.ListTaskSessions(ctx, id)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list task sessions for archive",
|
|
zap.String("task_id", id),
|
|
zap.Error(err))
|
|
}
|
|
|
|
var worktrees []*worktree.Worktree
|
|
if s.worktreeCleanup != nil {
|
|
if provider, ok := s.worktreeCleanup.(WorktreeProvider); ok {
|
|
worktrees, err = provider.GetAllByTaskID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list worktrees for archive",
|
|
zap.String("task_id", id),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, id)
|
|
|
|
// 3. Set archived_at in DB
|
|
if err := s.tasks.ArchiveTask(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 4. Re-read task for updated archived_at field
|
|
task, err = s.tasks.GetTask(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 5. Publish task.updated event so frontend removes from board
|
|
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
|
s.logger.Info("task archived",
|
|
zap.String("task_id", id),
|
|
zap.Duration("duration", time.Since(start)))
|
|
|
|
// 6. Background: Stop agents and cleanup worktrees
|
|
// Note: isEphemeral=false for archive to preserve quick-chat directories
|
|
envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: true}
|
|
if len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || taskEnv != nil {
|
|
s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, envCleanup, false,
|
|
"task archived", "failed to stop session on task archive", "task archive cleanup completed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteTask deletes a task and publishes a task.deleted event.
|
|
// For fast UI response, the DB delete and event publish happen synchronously,
|
|
// while agent stopping and worktree cleanup happen asynchronously.
|
|
func (s *Service) DeleteTask(ctx context.Context, id string) error {
|
|
start := time.Now()
|
|
|
|
// 1. Get task (sync, fast)
|
|
task, err := s.tasks.GetTask(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Gather data needed for cleanup BEFORE delete (sync, fast)
|
|
sessions, err := s.sessions.ListTaskSessions(ctx, id)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list task sessions for delete",
|
|
zap.String("task_id", id),
|
|
zap.Error(err))
|
|
}
|
|
|
|
worktrees := s.gatherWorktreesForDelete(ctx, id)
|
|
taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, id)
|
|
|
|
// 3. Get active sessions for stopping agents (sync, fast)
|
|
// Must query before delete since DB records will be gone
|
|
var stopTargets []taskStopTarget
|
|
if s.executionStopper != nil {
|
|
activeSessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, id)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list active sessions for delete",
|
|
zap.String("task_id", id),
|
|
zap.Error(err))
|
|
}
|
|
stopTargets = s.buildStopTargets(ctx, id, activeSessions)
|
|
}
|
|
|
|
// 4. Delete from DB (sync, fast)
|
|
if err := s.tasks.DeleteTask(ctx, id); err != nil {
|
|
s.logger.Error("failed to delete task", zap.String("task_id", id), zap.Error(err))
|
|
return err
|
|
}
|
|
|
|
// 5. Publish event (sync, fast) - frontend removes task immediately
|
|
s.publishTaskEvent(ctx, events.TaskDeleted, task, nil)
|
|
s.logger.Info("task deleted",
|
|
zap.String("task_id", id),
|
|
zap.Duration("duration", time.Since(start)))
|
|
|
|
// 6. Stop agents and cleanup worktrees in the background. Carry the
|
|
// envCleanup struct so the task environment row is reset alongside
|
|
// the worktrees (an extra task.taskEnv != nil branch keeps the
|
|
// cleanup running when only the env needs reclaiming).
|
|
envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: false}
|
|
hasCleanup := len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || task.IsEphemeral || taskEnv != nil
|
|
if hasCleanup {
|
|
s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, envCleanup, task.IsEphemeral,
|
|
"task deleted", "failed to stop session on task delete", "task cleanup completed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupTaskResources tears down a task's runtime resources (container,
|
|
// sandbox, worktree, executor_running rows, quick-chat dir, task_environment
|
|
// row) AFTER the task row has been archived or deleted by another path.
|
|
//
|
|
// Used by HandoffService.ArchiveTaskTree / DeleteTaskTree, which bypass
|
|
// Service.ArchiveTask / Service.DeleteTask and therefore miss the runtime
|
|
// teardown those wrappers run via runAsyncTaskCleanup. Without this call the
|
|
// agent gets stopped but its container/sandbox leaks indefinitely.
|
|
//
|
|
// The caller is expected to have cancelled active runs separately (cascade
|
|
// does this via runCanceller before invoking us), so stopTargets is empty.
|
|
// deleteEnvRow controls whether the task_environment row is removed (true
|
|
// for delete cascade, false for archive — archive preserves the row).
|
|
// Best-effort and idempotent; failures are logged.
|
|
func (s *Service) CleanupTaskResources(ctx context.Context, taskID string, deleteEnvRow bool) {
|
|
sessions, err := s.sessions.ListTaskSessions(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list sessions for cascade cleanup",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
}
|
|
worktrees := s.gatherWorktreesForDelete(ctx, taskID)
|
|
taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, taskID)
|
|
envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: deleteEnvRow}
|
|
if len(sessions) == 0 && len(worktrees) == 0 && taskEnv == nil {
|
|
return
|
|
}
|
|
reason := "cascade archive"
|
|
if deleteEnvRow {
|
|
reason = "cascade delete"
|
|
}
|
|
s.runAsyncTaskCleanup(taskID, sessions, worktrees, nil, envCleanup, false,
|
|
reason, "failed to stop session on cascade cleanup", "cascade cleanup completed")
|
|
}
|
|
|
|
// gatherWorktreesForDelete collects worktrees for a task before it is deleted.
|
|
// For legacy WorktreeCleanup implementations that do not implement WorktreeProvider,
|
|
// it triggers cleanup immediately and returns nil.
|
|
func (s *Service) gatherWorktreesForDelete(ctx context.Context, taskID string) []*worktree.Worktree {
|
|
if s.worktreeCleanup == nil {
|
|
return nil
|
|
}
|
|
provider, ok := s.worktreeCleanup.(WorktreeProvider)
|
|
if !ok {
|
|
// Fallback for legacy implementations: cleanup before delete.
|
|
if err := s.worktreeCleanup.OnTaskDeleted(ctx, taskID); err != nil {
|
|
s.logger.Warn("failed to cleanup worktree on task deletion",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
}
|
|
return nil
|
|
}
|
|
worktrees, err := provider.GetAllByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to list worktrees for delete",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
}
|
|
return worktrees
|
|
}
|
|
|
|
func (s *Service) gatherTaskEnvironmentForCleanup(ctx context.Context, taskID string) *models.TaskEnvironment {
|
|
if s.taskEnvironments == nil {
|
|
return nil
|
|
}
|
|
env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID)
|
|
if err != nil {
|
|
s.logger.Warn("failed to lookup task environment for cleanup",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
return nil
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (s *Service) runAsyncTaskCleanup(
|
|
id string,
|
|
sessions []*models.TaskSession,
|
|
worktrees []*worktree.Worktree,
|
|
stopTargets []taskStopTarget,
|
|
envCleanup taskEnvironmentCleanup,
|
|
isEphemeral bool,
|
|
stopReason, stopFailMsg, cleanupMsg string,
|
|
) {
|
|
go func() {
|
|
cleanupStart := time.Now()
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
if s.executionStopper != nil && len(stopTargets) > 0 {
|
|
for _, target := range stopTargets {
|
|
if target.executionID != "" {
|
|
if err := s.executionStopper.StopExecution(cleanupCtx, target.executionID, stopReason, true); err != nil {
|
|
s.logger.Warn(stopFailMsg,
|
|
zap.String("task_id", id),
|
|
zap.String("session_id", target.sessionID),
|
|
zap.String("execution_id", target.executionID),
|
|
zap.Error(err))
|
|
}
|
|
continue
|
|
}
|
|
if err := s.executionStopper.StopSession(cleanupCtx, target.sessionID, stopReason, true); err != nil {
|
|
s.logger.Warn(stopFailMsg,
|
|
zap.String("task_id", id),
|
|
zap.String("session_id", target.sessionID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees, envCleanup, isEphemeral)
|
|
|
|
if len(cleanupErrors) > 0 {
|
|
s.logger.Warn(cleanupMsg+" with errors",
|
|
zap.String("task_id", id),
|
|
zap.Int("error_count", len(cleanupErrors)),
|
|
zap.Duration("duration", time.Since(cleanupStart)))
|
|
} else {
|
|
s.logger.Info(cleanupMsg,
|
|
zap.String("task_id", id),
|
|
zap.Duration("duration", time.Since(cleanupStart)))
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *Service) buildStopTargets(ctx context.Context, taskID string, activeSessions []*models.TaskSession) []taskStopTarget {
|
|
targets := make([]taskStopTarget, 0, len(activeSessions))
|
|
for _, sess := range activeSessions {
|
|
if sess == nil || sess.ID == "" {
|
|
continue
|
|
}
|
|
target := taskStopTarget{
|
|
sessionID: sess.ID,
|
|
executionID: strings.TrimSpace(sess.AgentExecutionID),
|
|
}
|
|
if target.executionID == "" {
|
|
running, err := s.executors.GetExecutorRunningBySessionID(ctx, sess.ID)
|
|
if err == nil && running != nil {
|
|
target.executionID = strings.TrimSpace(running.AgentExecutionID)
|
|
}
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
s.logger.Debug("prepared task cleanup stop targets",
|
|
zap.String("task_id", taskID),
|
|
zap.Int("count", len(targets)))
|
|
return targets
|
|
}
|
|
|
|
// performTaskCleanup handles post-deletion cleanup operations.
|
|
// Handles worktree cleanup, executor_running records, and quick-chat workspace directories.
|
|
// Agent stopping is handled separately in the DeleteTask background goroutine.
|
|
// Returns a slice of errors encountered (empty if all succeeded).
|
|
func (s *Service) performTaskCleanup(
|
|
ctx context.Context,
|
|
taskID string,
|
|
sessions []*models.TaskSession,
|
|
worktrees []*worktree.Worktree,
|
|
envCleanup taskEnvironmentCleanup,
|
|
isEphemeral bool,
|
|
) []error {
|
|
var errs []error
|
|
|
|
errs = append(errs, s.cleanupTaskEnvironment(ctx, taskID, envCleanup)...)
|
|
worktrees = excludeEnvironmentWorktree(worktrees, envCleanup.env)
|
|
|
|
// Cleanup worktrees
|
|
if len(worktrees) > 0 {
|
|
if cleaner, ok := s.worktreeCleanup.(WorktreeBatchCleaner); ok {
|
|
if err := cleaner.CleanupWorktrees(ctx, worktrees); err != nil {
|
|
s.logger.Warn("failed to cleanup worktrees after delete",
|
|
zap.String("task_id", taskID),
|
|
zap.Error(err))
|
|
errs = append(errs, fmt.Errorf("cleanup worktrees: %w", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete executor running records for sessions
|
|
for _, session := range sessions {
|
|
if session == nil || session.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.executors.DeleteExecutorRunningBySessionID(ctx, session.ID); err != nil {
|
|
s.logger.Debug("failed to delete executor runtime for session",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", session.ID),
|
|
zap.Error(err))
|
|
// Don't add to errs - this is a debug-level issue
|
|
}
|
|
}
|
|
|
|
// Cleanup quick-chat workspace directories for all tasks (not just ephemeral).
|
|
// Non-ephemeral office tasks also get quick-chat dirs allocated by manager_launch.go;
|
|
// both cases must be cleaned up to avoid a disk leak.
|
|
if s.quickChatDir != "" {
|
|
for _, session := range sessions {
|
|
if session == nil || session.ID == "" {
|
|
continue
|
|
}
|
|
sessionDir := filepath.Join(s.quickChatDir, session.ID)
|
|
if _, statErr := os.Stat(sessionDir); statErr != nil {
|
|
// Directory does not exist — nothing to remove.
|
|
continue
|
|
}
|
|
if err := os.RemoveAll(sessionDir); err != nil {
|
|
s.logger.Warn("failed to cleanup quick-chat workspace directory",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", session.ID),
|
|
zap.String("path", sessionDir),
|
|
zap.Error(err))
|
|
errs = append(errs, fmt.Errorf("cleanup quick-chat dir %s: %w", session.ID, err))
|
|
} else {
|
|
s.logger.Debug("cleaned up quick-chat workspace directory",
|
|
zap.String("task_id", taskID),
|
|
zap.String("session_id", session.ID),
|
|
zap.String("path", sessionDir))
|
|
}
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func (s *Service) cleanupTaskEnvironment(
|
|
ctx context.Context,
|
|
taskID string,
|
|
cleanup taskEnvironmentCleanup,
|
|
) []error {
|
|
if cleanup.env == nil {
|
|
return nil
|
|
}
|
|
if err := s.teardownEnvironmentResources(ctx, cleanup.env); err != nil {
|
|
s.logger.Warn("failed to teardown task environment during task cleanup",
|
|
zap.String("task_id", taskID),
|
|
zap.String("env_id", cleanup.env.ID),
|
|
zap.Error(err))
|
|
return []error{fmt.Errorf("teardown task environment %s: %w", cleanup.env.ID, err)}
|
|
}
|
|
if cleanup.deleteRow {
|
|
if err := s.taskEnvironments.DeleteTaskEnvironment(ctx, cleanup.env.ID); err != nil {
|
|
s.logger.Warn("failed to delete task environment row during task cleanup",
|
|
zap.String("task_id", taskID),
|
|
zap.String("env_id", cleanup.env.ID),
|
|
zap.Error(err))
|
|
return []error{fmt.Errorf("delete task environment row %s: %w", cleanup.env.ID, err)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func excludeEnvironmentWorktree(worktrees []*worktree.Worktree, env *models.TaskEnvironment) []*worktree.Worktree {
|
|
if env == nil || env.WorktreeID == "" || len(worktrees) == 0 {
|
|
return worktrees
|
|
}
|
|
filtered := worktrees[:0]
|
|
for _, wt := range worktrees {
|
|
if wt == nil || wt.ID == env.WorktreeID {
|
|
continue
|
|
}
|
|
filtered = append(filtered, wt)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// ListTasks returns all tasks for a workflow
|
|
func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) {
|
|
tasks, err := s.tasks.ListTasks(ctx, workflowID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.loadTaskRepositoriesBatch(ctx, tasks); err != nil {
|
|
s.logger.Error("failed to batch-load task repositories", zap.Error(err))
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// ListTasksByWorkspace returns paginated tasks for a workspace with task repositories loaded.
|
|
// If query is non-empty, filters by task title, description, repository name, or repository path.
|
|
// workflowID and repositoryID, when non-empty, further restrict results to that workflow/repository.
|
|
func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) {
|
|
tasks, total, err := s.tasks.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, query, page, pageSize, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err := s.loadTaskRepositoriesBatch(ctx, tasks); err != nil {
|
|
s.logger.Error("failed to batch-load task repositories", zap.Error(err))
|
|
}
|
|
|
|
return tasks, total, nil
|
|
}
|
|
|
|
// loadTaskRepositoriesBatch loads repositories for multiple tasks in a single query.
|
|
func (s *Service) loadTaskRepositoriesBatch(ctx context.Context, tasks []*models.Task) error {
|
|
if len(tasks) == 0 {
|
|
return nil
|
|
}
|
|
taskIDs := make([]string, len(tasks))
|
|
for i, t := range tasks {
|
|
taskIDs[i] = t.ID
|
|
}
|
|
repoMap, err := s.taskRepos.ListTaskRepositoriesByTaskIDs(ctx, taskIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, task := range tasks {
|
|
task.Repositories = repoMap[task.ID]
|
|
}
|
|
return nil
|
|
}
|