Files
kandev/apps/backend/internal/task/service/service_tasks.go
T
João Salavisa 45a46f9899 feat: support tasks without a repository (#850)
* feat: support tasks without a repository

Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.

The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.

Spec + plan added at docs/specs/tasks-without-repositories/.

* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)

CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.

Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.

* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)

Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.

* fix: address PR review feedback

- Changes panel auto-close now distinguishes "task loading" from
  "task has no repos" (returns boolean | null), so the panel isn't
  permanently removed during the first-render window before task
  data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
  reopening the dialog after a None-mode task doesn't carry the
  flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
  (always equivalent to `i > 1`); chevrons now render between every
  segment as expected.
- list-dir HTTP handler returns a generic error message instead of
  forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
  the scratch-path computation so future readers don't mistake the
  filepath.Dir call for a fragile derivation.

* fix: address CodeRabbit review nitpicks

- Trim whitespace on workspace_path before persisting to task metadata
  so a value of "   " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
  alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
  so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
  reopening reloads from `value` instead of showing the last browsed
  directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
  string when the trimmed value is empty, matching the noRepository=false
  case and keeping the API contract clear.

* fix: scratch path lands in kandev home, not its parent

Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.

Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.

Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).

* fix: pass workspacePath through handleCreateWithoutAgent too

The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.

* fix: satisfy DialogFormState in new-subtask form stub

PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).

Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.

* fix: keep toggle-github-url testid as alias on URL-mode button

The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.

* test(e2e): update toggle assertion for new source-mode segmented control

create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.

* refactor: extract useActiveTaskHasRepos to its own hook file

dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.

* chore: gitignore .claude/scheduled_tasks.lock runtime file

* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle

The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.

* feat: let folder picker browse anywhere, drop allowed-roots gate

The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".

Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.

CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-12 01:10:22 +01:00

757 lines
25 KiB
Go

package service
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"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"
)
type taskStopTarget struct {
sessionID string
executionID string
}
// Task operations
// CreateTask creates a new task and publishes a task.created event.
// WorkflowID is required for non-ephemeral tasks (kanban tasks).
// Ephemeral tasks (quick chat, config chat) must NOT have a workflow.
func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) {
// Non-ephemeral tasks require a workflow for kanban board placement
if !req.IsEphemeral && req.WorkflowID == "" {
return nil, fmt.Errorf("workflow_id is required for non-ephemeral tasks")
}
// Ephemeral tasks must not have a workflow - they exist outside the kanban board
if req.IsEphemeral && req.WorkflowID != "" {
return nil, fmt.Errorf("workflow_id must be empty for ephemeral tasks")
}
// Auto-resolve start step if not provided
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
}
}
state := v1.TaskStateCreated
if req.State != nil {
state = *req.State
}
metadata := req.Metadata
workspacePath := strings.TrimSpace(req.WorkspacePath)
if workspacePath != "" {
if metadata == nil {
metadata = make(map[string]interface{})
}
metadata[models.MetaKeyWorkspacePath] = workspacePath
}
task := &models.Task{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
WorkflowID: req.WorkflowID,
WorkflowStepID: workflowStepID,
Title: req.Title,
Description: req.Description,
State: state,
Priority: req.Priority,
Position: req.Position,
Metadata: metadata,
IsEphemeral: req.IsEphemeral,
ParentID: req.ParentID,
}
if err := s.tasks.CreateTask(ctx, task); err != nil {
s.logger.Error("failed to create task", zap.Error(err))
return nil, 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
}
// 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-repo validation: each repository may appear at most once per task.
// Without this guard the unique (task_id, repository_id) constraint on
// task_repositories would surface as an opaque DB error mid-loop, leaving
// some rows already inserted.
if seen[repositoryID] {
return fmt.Errorf("repository %q listed more than once for the task", repositoryID)
}
seen[repositoryID] = true
taskRepo := &models.TaskRepository{
TaskID: taskID,
RepositoryID: repositoryID,
BaseBranch: baseBranch,
CheckoutBranch: repoInput.CheckoutBranch,
Position: i,
Metadata: make(map[string]interface{}),
}
if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("failed to create task repository", zap.Error(err))
return err
}
}
return nil
}
// 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
}
// 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 = &current
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))
}
}
}
// 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
if len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 {
s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, 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)
// 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. Return immediately - all remaining cleanup is async
// 7. Background: Stop agents and cleanup worktrees
if len(stopTargets) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 || task.IsEphemeral {
s.runAsyncTaskCleanup(id, sessions, worktrees, stopTargets, task.IsEphemeral,
"task deleted", "failed to stop session on task delete", "task cleanup completed")
}
return nil
}
// 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) runAsyncTaskCleanup(
id string,
sessions []*models.TaskSession,
worktrees []*worktree.Worktree,
stopTargets []taskStopTarget,
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, 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,
isEphemeral bool,
) []error {
var errs []error
// 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 ephemeral tasks
if isEphemeral && s.quickChatDir != "" {
for _, session := range sessions {
if session == nil || session.ID == "" {
continue
}
sessionDir := filepath.Join(s.quickChatDir, session.ID)
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
}
// 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
}