ad32c6b2cd
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
795 lines
36 KiB
Go
795 lines
36 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/common/fsdiagnostics"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"github.com/kandev/kandev/internal/task/repository"
|
|
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
|
"github.com/kandev/kandev/internal/worktree"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// WorktreeCleanup provides worktree cleanup on task deletion.
|
|
type WorktreeCleanup interface {
|
|
// OnTaskDeleted is called when a task is deleted to clean up its worktree.
|
|
OnTaskDeleted(ctx context.Context, taskID string) error
|
|
}
|
|
|
|
// CanvasCleanup removes plugin-backed canvas authority owned by a task or
|
|
// workspace. It is optional so focused task-service users do not need the
|
|
// canvas subsystem. Both cleanup methods run before their owning task or
|
|
// workspace delete commits, so release-artifact cleanup ownership is recorded
|
|
// before any canvas authority can become orphaned.
|
|
type CanvasCleanup interface {
|
|
CleanupTaskCanvases(ctx context.Context, taskID string) error
|
|
CleanupWorkspaceCanvases(ctx context.Context, workspaceID string) error
|
|
}
|
|
|
|
// WorkspaceSecretDeleter removes secrets owned by a workspace. It is optional
|
|
// for isolated task-service users.
|
|
type WorkspaceSecretDeleter interface {
|
|
DeleteWorkspaceSecrets(ctx context.Context, workspaceID string) error
|
|
}
|
|
|
|
type transactionalWorkspaceCascade interface {
|
|
DeleteWorkspaceCascadeWithSecretCleanup(
|
|
ctx context.Context,
|
|
id string,
|
|
cleanup func(context.Context, *sqlx.Tx) error,
|
|
) ([]*models.Task, []*models.Workflow, error)
|
|
DeleteWorkspaceCascadeWithNameAndSecretCleanup(
|
|
ctx context.Context,
|
|
id, name string,
|
|
cleanup func(context.Context, *sqlx.Tx) error,
|
|
) ([]*models.Task, []*models.Workflow, error)
|
|
}
|
|
|
|
// WorktreeProvider extends WorktreeCleanup with query capabilities.
|
|
// Implementations that support this can be type-asserted from WorktreeCleanup.
|
|
type WorktreeProvider interface {
|
|
WorktreeCleanup
|
|
// GetAllByTaskID returns all worktrees associated with a task.
|
|
GetAllByTaskID(ctx context.Context, taskID string) ([]*worktree.Worktree, error)
|
|
}
|
|
|
|
// WorktreeCleanupIdentityProvider captures immutable checkout identities before
|
|
// a durable cleanup snapshot is stored. Implementations that do not provide it
|
|
// remain compatible with legacy cleanup wiring, which uses the older live-state
|
|
// fallback in the worktree manager.
|
|
type WorktreeCleanupIdentityProvider interface {
|
|
CaptureCleanupHeadOIDs(ctx context.Context, worktrees []*worktree.Worktree) (map[string]string, error)
|
|
}
|
|
|
|
// WorktreeDirtyInspector reports local changes before a task deletion mutates
|
|
// task rows or persists a cleanup job.
|
|
type WorktreeDirtyInspector interface {
|
|
InspectDirtyWorktrees(ctx context.Context, worktrees []*worktree.Worktree) ([]worktree.DirtyWorktree, error)
|
|
}
|
|
|
|
// WorktreeBatchCleaner extends WorktreeProvider with batch cleanup.
|
|
type WorktreeBatchCleaner interface {
|
|
WorktreeProvider
|
|
// CleanupWorktrees removes multiple worktrees in a single operation.
|
|
CleanupWorktrees(ctx context.Context, worktrees []*worktree.Worktree) error
|
|
}
|
|
|
|
// WorktreeBatchCleanerWithOptions is the consent-aware cleanup extension. The
|
|
// legacy batch method remains available for archive and non-consented cleanup.
|
|
type WorktreeBatchCleanerWithOptions interface {
|
|
WorktreeBatchCleaner
|
|
CleanupWorktreesWithOptions(
|
|
ctx context.Context,
|
|
worktrees []*worktree.Worktree,
|
|
options worktree.WorktreeCleanupOptions,
|
|
) error
|
|
}
|
|
|
|
// DeleteTaskOptions controls destructive task deletion behavior.
|
|
type DeleteTaskOptions struct {
|
|
DiscardWorktreeChanges bool
|
|
}
|
|
|
|
// WorktreeArchiveBatchCleaner removes archived task worktrees without deleting
|
|
// the local branches required for later recovery.
|
|
type WorktreeArchiveBatchCleaner interface {
|
|
WorktreeBatchCleaner
|
|
CleanupWorktreesPreservingBranches(ctx context.Context, worktrees []*worktree.Worktree) error
|
|
}
|
|
|
|
type worktreeReferenceGuard interface {
|
|
CountActiveWorktreeReferences(ctx context.Context, worktreeID string, excludeSessionIDs []string) (int, error)
|
|
ReleaseWorktreeReference(ctx context.Context, wt *worktree.Worktree) error
|
|
}
|
|
|
|
// TaskExecutionStopper stops active task execution (agent session + instance).
|
|
type TaskExecutionStopper interface {
|
|
StopTask(ctx context.Context, taskID, reason string, force bool) error
|
|
StopSession(ctx context.Context, sessionID, reason string, force bool) error
|
|
StopExecution(ctx context.Context, executionID, reason string, force bool) error
|
|
// RegisterExecutionStopOwner records exact teardown ownership before a
|
|
// terminal session mutation. It never replaces the explicit stop call.
|
|
RegisterExecutionStopOwner(sessionID, executionID string, force bool)
|
|
}
|
|
|
|
// synchronousTaskExecutionStopper is an optional cleanup-only extension. The
|
|
// normal StopSession contract schedules process teardown asynchronously, but
|
|
// destructive resource cleanup must wait until the process exits.
|
|
type synchronousTaskExecutionStopper interface {
|
|
StopSessionSynchronously(ctx context.Context, sessionID, reason string, force bool) error
|
|
}
|
|
|
|
// TerminalClarificationCanceller expires durable input requests after a task
|
|
// service-owned terminal transition, such as archive cancellation.
|
|
type TerminalClarificationCanceller interface {
|
|
ExpireSessionAndNotify(ctx context.Context, sessionID string) (int, error)
|
|
}
|
|
|
|
// ParkedProjectionCanceller clears the orchestrator's in-memory
|
|
// parked_on_background_work projection (spec: docs/specs/disambiguate-waiting)
|
|
// for a session terminated through a task service-owned bulk path — archive's
|
|
// batch session cancellation and delete's cascaded session removal — neither
|
|
// of which goes through the orchestrator's own per-session state-transition
|
|
// chokepoint. newState mirrors the D8 session-state term: pass the session's
|
|
// new terminal state (e.g. CANCELLED) for archive, or "" for delete, matching
|
|
// the same convention the orchestrator uses for its own session-deleted path.
|
|
type ParkedProjectionCanceller interface {
|
|
ClearParkedProjectionOnSessionTerminated(ctx context.Context, taskID, sessionID string, newState models.TaskSessionState)
|
|
}
|
|
|
|
// TaskRowLivenessProber classifies an executors_running row's backing-process
|
|
// liveness in a runtime-aware way (a local process check is never applied to a
|
|
// remote/SSH row). It is optional and satisfied by the lifecycle adapter. When
|
|
// unwired, cleanup treats every row as Unknown so a not-found stop is never
|
|
// mistaken for an absent runtime.
|
|
type TaskRowLivenessProber interface {
|
|
RowLiveness(row *models.ExecutorRunning) models.ProcessLiveness
|
|
}
|
|
|
|
// TaskResourceCleanupActivityGate serializes durable cleanup with install-wide maintenance.
|
|
type TaskResourceCleanupActivityGate interface {
|
|
AcquireTaskResourceCleanup(context.Context) (TaskResourceCleanupActivityLease, error)
|
|
}
|
|
|
|
type TaskResourceCleanupActivityLease interface {
|
|
Release()
|
|
}
|
|
|
|
// ProviderDefaultBranchProber resolves a provider repo's default branch
|
|
// (e.g. "main" / "master") without requiring a local clone. Used by
|
|
// AddBranchToTask to satisfy the worktree-create precondition synchronously,
|
|
// since add_branch does not trigger the executor-side backfillRepoDefaultBranch
|
|
// path. Default implementation (cmd/kandev) shells out to
|
|
// `git ls-remote --symref`; tests inject a stub via SetProviderDefaultBranchProber.
|
|
//
|
|
// Implementations MUST honour ctx cancellation so a slow / hung remote does not
|
|
// stall the calling MCP tool. Returns ("", error) on probe failure — callers
|
|
// fall through to the explicit "cannot resolve base_branch" rejection rather
|
|
// than persisting an empty-default row.
|
|
type ProviderDefaultBranchProber interface {
|
|
ProbeDefaultBranch(ctx context.Context, provider, owner, name string) (string, error)
|
|
}
|
|
|
|
// BranchMaterializer creates a worktree on disk and persists the
|
|
// task_environment_repos row for a newly added task_repository row, without
|
|
// restarting the agent. Used by AddBranchToTask so MCP-driven "add a branch
|
|
// to this task" actually surfaces the new worktree in the UI on the next
|
|
// poll, rather than waiting for a session relaunch.
|
|
//
|
|
// The implementation lives in cmd/kandev (it needs worktree.Manager, the
|
|
// session/env repos, and the repository entity layer) — the service layer
|
|
// only knows the abstract capability.
|
|
// AgentBaseBranchPusher pushes an updated per-repo base-branch map to the
|
|
// agentctl instance(s) of any running execution for a task. Used by
|
|
// UpdateRepositoryBaseBranch so the changes-panel "Compare against" picker
|
|
// updates BaseCommit / Ahead / Behind live, not just at next session start.
|
|
// Implementations must be no-op-safe when the task has no running execution.
|
|
type AgentBaseBranchPusher interface {
|
|
PushBaseBranchesForTask(ctx context.Context, taskID string, branches map[string]string)
|
|
}
|
|
|
|
// AgentComparisonTargetPusher pushes the durable provider-qualified comparison
|
|
// target map to every running agentctl execution for a task. Implementations
|
|
// must replace the complete projection, including an empty map, so a cleared
|
|
// target cannot remain cached in a live workspace.
|
|
type AgentComparisonTargetPusher interface {
|
|
PushComparisonTargetsForTask(ctx context.Context, taskID string, targets map[string]models.ComparisonTarget)
|
|
}
|
|
|
|
type BranchMaterializer interface {
|
|
// MaterializeBranch creates the worktree for a freshly inserted
|
|
// task_repositories row. Best-effort: when no active session exists yet
|
|
// the implementation may choose to no-op and let the next session launch
|
|
// create the worktree via the standard multi-repo prepare path.
|
|
MaterializeBranch(ctx context.Context, taskID, taskRepositoryID string) (*BranchMaterializationResult, error)
|
|
}
|
|
|
|
// BranchMaterializationResult describes the live worktree created for a
|
|
// branch attachment. A nil result with a nil error means materialization was
|
|
// intentionally deferred until the next session launch; callers must check
|
|
// for a nil result rather than empty path fields.
|
|
type BranchMaterializationResult struct {
|
|
WorktreePath string
|
|
TaskWorkspacePath string
|
|
}
|
|
|
|
// GitArchiveCapture captures git state (commits, cumulative diff) when a task is archived.
|
|
// This allows preserving the final git state of a session for historical purposes.
|
|
type GitArchiveCapture interface {
|
|
// CaptureArchiveSnapshot captures the git state for a session before archiving.
|
|
// Returns nil if capture is not possible (e.g., agent not running).
|
|
CaptureArchiveSnapshot(ctx context.Context, sessionID string) error
|
|
}
|
|
|
|
// WorkflowStepCreator creates workflow steps from a template for a workflow.
|
|
type WorkflowStepCreator interface {
|
|
CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error
|
|
}
|
|
|
|
// WorkspaceBootstrapper owns the atomic persistence of a standard Kanban
|
|
// workspace and its initial workflow state.
|
|
type WorkspaceBootstrapper interface {
|
|
CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error)
|
|
}
|
|
|
|
// WorkspaceDefaultsInitializer persists integration defaults after a
|
|
// workspace row exists and before its creation event is published.
|
|
type WorkspaceDefaultsInitializer interface {
|
|
InitializeWorkspaceDefaults(ctx context.Context, workspaceID string) error
|
|
}
|
|
|
|
// WorkflowStepGetter retrieves workflow step information.
|
|
type WorkflowStepGetter interface {
|
|
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
|
|
// GetNextStepByPosition returns the next step after the given position for a workflow.
|
|
// Returns nil if there is no next step (i.e., current step is the last one).
|
|
GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error)
|
|
}
|
|
|
|
// workflowStepLister is an optional extension used to find WIP steps that
|
|
// pull work from a feeder when new work arrives in that feeder.
|
|
type workflowStepLister interface {
|
|
ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error)
|
|
}
|
|
|
|
// PRTaskResolver resolves which tasks are associated with a GitHub PR number.
|
|
// Implemented by the github service; injected so the task service can surface a
|
|
// task by its PR number in search without coupling to the github schema.
|
|
type PRTaskResolver interface {
|
|
FindTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, error)
|
|
}
|
|
|
|
// StartStepResolver resolves the starting step for a workflow.
|
|
type StartStepResolver interface {
|
|
ResolveStartStep(ctx context.Context, workflowID string) (string, error)
|
|
ResolveFirstStep(ctx context.Context, workflowID string) (string, error)
|
|
ResolveAutoStartStep(ctx context.Context, workflowID string) (string, error)
|
|
}
|
|
|
|
// StepHistoryRecorder persists an ADR 0015 session-step transition audit
|
|
// row. Optional — when unset, MoveTaskWithOptions records nothing. Errors
|
|
// are logged and swallowed by the caller: the audit trail must never fail
|
|
// the move it is recording.
|
|
type StepHistoryRecorder interface {
|
|
CreateStepTransition(ctx context.Context, sessionID, fromStepID, toStepID string, trigger wfmodels.StepTransitionTrigger, actorID *string, metadata map[string]interface{}) error
|
|
}
|
|
|
|
type asyncStepHistoryRecorder interface {
|
|
EnqueueStepTransition(sessionID, fromStepID, toStepID string, trigger wfmodels.StepTransitionTrigger, actorID *string, metadata map[string]interface{}) bool
|
|
}
|
|
|
|
// ContributionDestinationPreparer is an internal creation-time hook for a
|
|
// server-owned publication route. It runs after request/workflow validation
|
|
// and external-ID deduplication, but before the task row is inserted.
|
|
type ContributionDestinationPreparer interface {
|
|
PrepareContributionDestination(
|
|
ctx context.Context,
|
|
req *CreateTaskRequest,
|
|
workflow *models.Workflow,
|
|
repositories []*models.Repository,
|
|
) error
|
|
}
|
|
|
|
var (
|
|
ErrActiveTaskSessions = errors.New("active agent sessions exist")
|
|
ErrWIPLimitExceeded = wfmodels.ErrWIPLimitExceeded
|
|
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
|
|
ErrInvalidExecutorConfig = errors.New("invalid executor config")
|
|
// Workspace-source sentinels are the service boundary consumed by the HTTP
|
|
// and MCP adapters. Keep categories stable rather than making callers parse
|
|
// a validation or runtime error string.
|
|
ErrInvalidWorkspaceSource = errors.New("invalid workspace source")
|
|
ErrWorkspaceSourceConflict = errors.New("workspace source conflict")
|
|
ErrWorkspaceSourceActive = errors.New("workspace source task is active")
|
|
ErrUnsupportedWorkspaceSource = errors.New("unsupported workspace source")
|
|
ErrWorkspaceSourceMaterialize = errors.New("workspace source materialization failed")
|
|
)
|
|
|
|
func validateExecutorConfig(config map[string]string) error {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
policy := strings.TrimSpace(config["mcp_policy"])
|
|
if policy == "" {
|
|
return nil
|
|
}
|
|
var decoded any
|
|
if err := json.Unmarshal([]byte(policy), &decoded); err != nil {
|
|
return fmt.Errorf("%w: mcp_policy must be valid JSON", ErrInvalidExecutorConfig)
|
|
}
|
|
if _, ok := decoded.(map[string]any); !ok {
|
|
return fmt.Errorf("%w: mcp_policy must be a JSON object", ErrInvalidExecutorConfig)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Repos holds the repository sub-interfaces used by the task service.
|
|
type Repos struct {
|
|
Workspaces repository.WorkspaceRepository
|
|
Tasks repository.TaskRepository
|
|
TaskRepos repository.TaskRepoRepository
|
|
WorkspaceFolders repository.TaskWorkspaceFolderRepository
|
|
Workflows repository.WorkflowRepository
|
|
Messages repository.MessageRepository
|
|
Attachments repository.AttachmentRepository
|
|
Turns repository.TurnRepository
|
|
Sessions repository.SessionRepository
|
|
GitSnapshots repository.GitSnapshotRepository
|
|
RepoEntities repository.RepositoryEntityRepository
|
|
DiscoveryRoots repository.DesktopDiscoveryRootRepository
|
|
RepositorySets repository.RepositorySetRepository
|
|
BranchPolicies repository.RepositoryBranchPolicyRepository
|
|
RepositoryCleanup repository.RepositoryCleanupRepository
|
|
Executors repository.ExecutorRepository
|
|
Environments repository.EnvironmentRepository
|
|
TaskEnvironments repository.TaskEnvironmentRepository
|
|
Reviews repository.ReviewRepository
|
|
ResourceCleanups repository.TaskResourceCleanupRepository
|
|
StatusSummaries repository.TaskStatusSummaryRepository
|
|
TaskActivity repository.TaskActivityRepository
|
|
SubagentContexts repository.SubagentContextRepository
|
|
Usage repository.UsageRepository
|
|
}
|
|
|
|
// Service provides task business logic
|
|
type Service struct {
|
|
workspaces repository.WorkspaceRepository
|
|
userDirectory UserDirectory
|
|
unitPlacer UnitPlacer
|
|
unitReach UnitReachResolver
|
|
userOrgs func(ctx context.Context, userID string) (string, error)
|
|
tasks repository.TaskRepository
|
|
taskRepos repository.TaskRepoRepository
|
|
workspaceFolders repository.TaskWorkspaceFolderRepository
|
|
workflows repository.WorkflowRepository
|
|
messages repository.MessageRepository
|
|
attachments repository.AttachmentRepository
|
|
turns repository.TurnRepository
|
|
sessions repository.SessionRepository
|
|
gitSnapshots repository.GitSnapshotRepository
|
|
repoEntities repository.RepositoryEntityRepository
|
|
desktopRootStore repository.DesktopDiscoveryRootRepository
|
|
repositorySets repository.RepositorySetRepository
|
|
branchPolicies repository.RepositoryBranchPolicyRepository
|
|
repositoryCleanup repository.RepositoryCleanupRepository
|
|
executors repository.ExecutorRepository
|
|
environments repository.EnvironmentRepository
|
|
taskEnvironments repository.TaskEnvironmentRepository
|
|
reviews repository.ReviewRepository
|
|
resourceCleanups repository.TaskResourceCleanupRepository
|
|
statusSummaries repository.TaskStatusSummaryRepository
|
|
taskActivity repository.TaskActivityRepository
|
|
subagentContexts repository.SubagentContextRepository
|
|
usage repository.UsageRepository
|
|
workspacePolicyAttacher WorkspacePolicyAttacher
|
|
attachmentSvc *AttachmentService
|
|
statusSummaryPRs TaskStatusSummaryPRReader
|
|
statusSummaryProjector TaskStatusSummaryEventProjector
|
|
queuedPromptCounter QueuedPromptCounter
|
|
eventBus bus.EventBus
|
|
logger *logger.Logger
|
|
discoveryConfig RepositoryDiscoveryConfig
|
|
discoveryCacheMu sync.Mutex
|
|
discoveryCache map[string]discoveryCacheEntry
|
|
discoveryFlights map[string]*discoveryFlight
|
|
discoveryNow func() time.Time
|
|
discoveryScanRoot func(context.Context, string, int) ([]LocalRepository, error)
|
|
filesystemWarnings *fsdiagnostics.WarningLimiter
|
|
worktreeCleanup WorktreeCleanup
|
|
canvasCleanup CanvasCleanup
|
|
executionStopper TaskExecutionStopper
|
|
clarificationCanceller TerminalClarificationCanceller
|
|
parkedProjectionCanceller ParkedProjectionCanceller
|
|
rowLivenessProber TaskRowLivenessProber
|
|
contextWindowResetter func(context.Context, string) error
|
|
cleanupActivity TaskResourceCleanupActivityGate
|
|
branchMaterializer BranchMaterializer
|
|
workspaceSourceMaterializer WorkspaceSourceMaterializer
|
|
workspaceSourceLocksMu sync.Mutex
|
|
workspaceSourceLocks map[string]*sync.Mutex
|
|
providerProber ProviderDefaultBranchProber
|
|
gitArchiveCapture GitArchiveCapture
|
|
workflowStepCreator WorkflowStepCreator
|
|
workspaceBootstrapper WorkspaceBootstrapper
|
|
workflowStepGetter WorkflowStepGetter
|
|
startStepResolver StartStepResolver
|
|
stepHistoryRecorder StepHistoryRecorder
|
|
contributionDestinationPreparer ContributionDestinationPreparer
|
|
prTaskResolver PRTaskResolver
|
|
quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat)
|
|
branchFetcher *branchFetcher
|
|
envDestroyer EnvironmentDestroyer
|
|
sshTaskDirReclaimer SSHTaskDirReclaimer
|
|
sessionRunningChecker SessionRunningChecker
|
|
remoteBranchLister RemoteBranchLister
|
|
repositorySelectionResolver RepositorySelectionResolver
|
|
repoCloneLocation RepoCloneLocation
|
|
blockers BlockerRepository
|
|
comments CommentRepository
|
|
taskStateActivity TaskStateActivityLogger
|
|
secretStore secrets.SecretStore
|
|
workspaceSecretDeleter WorkspaceSecretDeleter
|
|
baseBranchPusher AgentBaseBranchPusher
|
|
comparisonTargetPusher AgentComparisonTargetPusher
|
|
runtimeOverridesMu sync.Mutex
|
|
|
|
workspaceSourceProviderRefresher WorkspaceSourceProviderRefresher
|
|
|
|
workspaceDefaultsInitializer WorkspaceDefaultsInitializer
|
|
// foregroundActivity resolves the live fine-grained busy substate of a RUNNING
|
|
// session (satisfied by the orchestrator). Used to compute the task-level
|
|
// MOST-ACTIVE-WINS activity aggregate carried on task.updated events. Optional.
|
|
foregroundActivity ForegroundActivityProvider
|
|
// taskParkedProvider resolves the task-level parked_on_background_work
|
|
// OR-aggregate and its own monotonic revision (satisfied by the
|
|
// orchestrator; spec: docs/specs/disambiguate-waiting/spec.md). Carried on
|
|
// task.updated events. Optional — unset omits the field's live update path
|
|
// and task.updated payloads read false/0/0, matching D9's defaults.
|
|
taskParkedProvider TaskParkedProvider
|
|
// taskActivityMu guards lastTaskActivity, the last task-level activity aggregate
|
|
// emitted per task. It bounds live-propagation task.updated emissions to an
|
|
// actual change of the aggregated three-state value.
|
|
taskActivityMu sync.Mutex
|
|
lastTaskActivity map[string]v1.ForegroundActivity
|
|
lastTaskSubagentCount map[string]int
|
|
// taskPublicationMu guards the per-task FIFO dispatchers. It is held only
|
|
// while enqueueing/dequeueing; repository reads and synchronous EventBus
|
|
// delivery always happen after it is released.
|
|
taskPublicationMu sync.Mutex
|
|
taskPublications map[string]*taskPublicationQueue
|
|
// cleanupDoneForTest lets unit tests wait for async cleanup; nil in production.
|
|
cleanupDoneForTest chan struct{}
|
|
cleanupWorkerMu sync.Mutex
|
|
cleanupWorkerCancel context.CancelFunc
|
|
cleanupWorkerWG sync.WaitGroup
|
|
cleanupWorkerWake chan struct{}
|
|
cleanupRunsMu sync.Mutex
|
|
cleanupRuns map[*taskResourceCleanupRun]struct{}
|
|
// repoResolveMu serializes the check-then-create sections of
|
|
// FindOrCreateRepository and FindOrCreateRepositoryByLocalPath so two
|
|
// resolvers racing to register the same not-yet-known repository (by
|
|
// provider identity or by canonical local_path) converge on a single row
|
|
// instead of each inserting a duplicate. Covers concurrent requests
|
|
// within this backend process only — this backend is single-process per
|
|
// SQLite database, so that is the complete threat model today.
|
|
repoResolveMu sync.Mutex
|
|
// pendingActionProjectionMu guards the durable-generation logical clock
|
|
// shared by REST snapshots and semantic message events. Revisions are
|
|
// reserved before each repository read so delayed results stay ordered.
|
|
pendingActionProjectionMu sync.Mutex
|
|
pendingActionProjectionEpoch string
|
|
pendingActionProjectionSequence uint64
|
|
lastPendingActionProjections map[string]pendingActionProjectionState
|
|
}
|
|
|
|
// WorkspacePolicyAttacher persists the workspace-group relationship that is
|
|
// required before a newly created child can be returned or launched.
|
|
type WorkspacePolicyAttacher interface {
|
|
AttachWorkspacePolicy(ctx context.Context, taskID, parentID string, policy WorkspacePolicy) error
|
|
}
|
|
|
|
// WorkspacePolicyMembershipReleaser removes a task's workspace-group
|
|
// membership after a post-create rollback. It is an optional companion to
|
|
// WorkspacePolicyAttacher because lightweight task-service test harnesses may
|
|
// not persist workspace groups.
|
|
type WorkspacePolicyMembershipReleaser interface {
|
|
ReleaseWorkspacePolicy(ctx context.Context, taskID, reason string) error
|
|
}
|
|
|
|
// SetAttachmentService wires the file-backed prompt attachment owner into the
|
|
// task service. It is optional for focused unit-test harnesses that never send
|
|
// file-backed descriptors.
|
|
func (s *Service) SetAttachmentService(attachments *AttachmentService) {
|
|
s.attachmentSvc = attachments
|
|
}
|
|
|
|
// AttachmentService returns the optional file-backed attachment owner wired
|
|
// into this task service. It lets route and maintenance composition reuse the
|
|
// same storage boundary instead of creating competing service instances.
|
|
func (s *Service) AttachmentService() *AttachmentService {
|
|
return s.attachmentSvc
|
|
}
|
|
|
|
// AttachmentRepository returns the attachment registry repository used by the
|
|
// task service. It is exposed for composition of the storage maintenance hook.
|
|
func (s *Service) AttachmentRepository() repository.AttachmentRepository {
|
|
return s.attachments
|
|
}
|
|
|
|
// SetSecretStore wires metadata-only validation for shared executor profiles.
|
|
// Workspace-scoped secret references are rejected before a profile is saved.
|
|
func (s *Service) SetSecretStore(secretStore secrets.SecretStore) {
|
|
s.secretStore = secretStore
|
|
}
|
|
|
|
// SetWorkspaceSecretDeleter wires workspace-secret cleanup to workspace
|
|
// deletion. The callback runs only after the repository cascade succeeds.
|
|
func (s *Service) SetWorkspaceSecretDeleter(deleter WorkspaceSecretDeleter) {
|
|
s.workspaceSecretDeleter = deleter
|
|
}
|
|
|
|
// SetWorkspacePolicyAttacher installs the canonical child-workspace
|
|
// coordinator used by every CreateTask caller.
|
|
func (s *Service) SetWorkspacePolicyAttacher(attacher WorkspacePolicyAttacher) {
|
|
s.workspacePolicyAttacher = attacher
|
|
}
|
|
|
|
// NewService creates a new task service
|
|
func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service {
|
|
return &Service{
|
|
workspaces: repos.Workspaces,
|
|
tasks: repos.Tasks,
|
|
taskRepos: repos.TaskRepos,
|
|
workspaceFolders: repos.WorkspaceFolders,
|
|
workflows: repos.Workflows,
|
|
messages: repos.Messages,
|
|
attachments: repos.Attachments,
|
|
turns: repos.Turns,
|
|
sessions: repos.Sessions,
|
|
gitSnapshots: repos.GitSnapshots,
|
|
repoEntities: repos.RepoEntities,
|
|
desktopRootStore: repos.DiscoveryRoots,
|
|
repositorySets: repos.RepositorySets,
|
|
branchPolicies: repos.BranchPolicies,
|
|
repositoryCleanup: repos.RepositoryCleanup,
|
|
executors: repos.Executors,
|
|
environments: repos.Environments,
|
|
taskEnvironments: repos.TaskEnvironments,
|
|
reviews: repos.Reviews,
|
|
resourceCleanups: repos.ResourceCleanups,
|
|
statusSummaries: repos.StatusSummaries,
|
|
taskActivity: repos.TaskActivity,
|
|
subagentContexts: repos.SubagentContexts,
|
|
usage: repos.Usage,
|
|
eventBus: eventBus,
|
|
logger: log,
|
|
discoveryConfig: discoveryConfig,
|
|
discoveryCache: make(map[string]discoveryCacheEntry),
|
|
discoveryFlights: make(map[string]*discoveryFlight),
|
|
discoveryNow: time.Now,
|
|
discoveryScanRoot: scanRootForRepos,
|
|
filesystemWarnings: fsdiagnostics.NewWarningLimiter(0),
|
|
branchFetcher: newBranchFetcher(log.Zap()),
|
|
lastTaskActivity: make(map[string]v1.ForegroundActivity),
|
|
lastTaskSubagentCount: make(map[string]int),
|
|
// Focused service tests do not run backend composition. Production
|
|
// replaces this fallback with a database-allocated generation.
|
|
pendingActionProjectionEpoch: "1",
|
|
lastPendingActionProjections: make(map[string]pendingActionProjectionState),
|
|
}
|
|
}
|
|
|
|
// SetWorktreeCleanup sets the worktree cleanup handler for task deletion.
|
|
func (s *Service) SetWorktreeCleanup(cleanup WorktreeCleanup) {
|
|
s.worktreeCleanup = cleanup
|
|
}
|
|
|
|
// SetCanvasCleanup wires lifecycle cleanup for plugin-backed canvases.
|
|
func (s *Service) SetCanvasCleanup(cleanup CanvasCleanup) {
|
|
s.canvasCleanup = cleanup
|
|
}
|
|
|
|
func (s *Service) setCleanupDoneForTestHook(ch chan struct{}) {
|
|
s.cleanupDoneForTest = ch
|
|
}
|
|
|
|
// SetBranchMaterializer wires the mid-session worktree materializer for
|
|
// AddBranchToTask. Optional — when unset, MCP add_branch only inserts the
|
|
// task_repositories row and the worktree appears on next session launch.
|
|
func (s *Service) SetBranchMaterializer(m BranchMaterializer) {
|
|
s.branchMaterializer = m
|
|
}
|
|
|
|
func (s *Service) SetWorkspaceSourceMaterializer(m WorkspaceSourceMaterializer) {
|
|
s.workspaceSourceMaterializer = m
|
|
}
|
|
|
|
// SetWorkspaceSourceProviderRefresher wires the best-effort live MCP provider
|
|
// reconciliation used after workspace-source and legacy branch attachments.
|
|
func (s *Service) SetWorkspaceSourceProviderRefresher(r WorkspaceSourceProviderRefresher) {
|
|
s.workspaceSourceProviderRefresher = r
|
|
}
|
|
|
|
// SetAgentBaseBranchPusher wires the live-update push for
|
|
// UpdateRepositoryBaseBranch. Optional — when unset, the persisted DB value
|
|
// is the source of truth and the new base branch takes effect at next
|
|
// session launch.
|
|
func (s *Service) SetAgentBaseBranchPusher(p AgentBaseBranchPusher) {
|
|
s.baseBranchPusher = p
|
|
}
|
|
|
|
// SetAgentComparisonTargetPusher wires the live-update push for provider PR
|
|
// and MR reconciliation. Optional: when unset, the persisted attachment
|
|
// metadata remains authoritative and is hydrated at the next launch.
|
|
func (s *Service) SetAgentComparisonTargetPusher(p AgentComparisonTargetPusher) {
|
|
s.comparisonTargetPusher = p
|
|
}
|
|
|
|
// SetProviderDefaultBranchProber wires the synchronous default-branch probe
|
|
// used by AddBranchToTask's GitHub-URL resolution. Optional — when unset,
|
|
// add_branch with a provider URL and no base_branch falls through to the
|
|
// "cannot resolve base_branch" rejection instead of persisting an empty row.
|
|
func (s *Service) SetProviderDefaultBranchProber(p ProviderDefaultBranchProber) {
|
|
s.providerProber = p
|
|
}
|
|
|
|
// SetExecutionStopper wires the task execution stopper (orchestrator).
|
|
func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
|
|
s.executionStopper = stopper
|
|
}
|
|
|
|
// SetClarificationCanceller wires terminal clarification cleanup for session
|
|
// transitions owned by the task service.
|
|
func (s *Service) SetClarificationCanceller(canceller TerminalClarificationCanceller) {
|
|
s.clarificationCanceller = canceller
|
|
}
|
|
|
|
// SetParkedProjectionCanceller wires parked-projection cleanup (orchestrator)
|
|
// for session terminations driven by the task service's own bulk paths —
|
|
// archive cancellation and delete cascade — which never pass through the
|
|
// orchestrator's per-session state-transition chokepoint.
|
|
func (s *Service) SetParkedProjectionCanceller(canceller ParkedProjectionCanceller) {
|
|
s.parkedProjectionCanceller = canceller
|
|
}
|
|
|
|
// SetRowLivenessProber wires the runtime-aware executors_running liveness probe
|
|
// (satisfied by the lifecycle adapter). It is optional; when unwired, cleanup
|
|
// treats every row as Unknown.
|
|
func (s *Service) SetRowLivenessProber(prober TaskRowLivenessProber) {
|
|
s.rowLivenessProber = prober
|
|
}
|
|
|
|
// SetContextWindowResetter wires the guarded context-window reset callback
|
|
// owned by the orchestrator. It is optional for isolated task-service users;
|
|
// those callers fall back to clearing the session metadata directly.
|
|
func (s *Service) SetContextWindowResetter(resetter func(context.Context, string) error) {
|
|
s.contextWindowResetter = resetter
|
|
}
|
|
|
|
func (s *Service) resetContextWindow(ctx context.Context, sessionID string) error {
|
|
if s.contextWindowResetter != nil {
|
|
return s.contextWindowResetter(ctx, sessionID)
|
|
}
|
|
return s.sessions.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
|
|
}
|
|
|
|
func (s *Service) SetTaskResourceCleanupActivityGate(gate TaskResourceCleanupActivityGate) {
|
|
s.cleanupActivity = gate
|
|
}
|
|
|
|
// SetGitArchiveCapture wires the git archive capture handler.
|
|
func (s *Service) SetGitArchiveCapture(capture GitArchiveCapture) {
|
|
s.gitArchiveCapture = capture
|
|
}
|
|
|
|
// SetWorkflowStepCreator wires the workflow step creator for workflow creation.
|
|
func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
|
|
s.workflowStepCreator = creator
|
|
}
|
|
|
|
func (s *Service) SetWorkspaceBootstrapper(bootstrapper WorkspaceBootstrapper) {
|
|
s.workspaceBootstrapper = bootstrapper
|
|
}
|
|
|
|
// SetWorkspaceDefaultsInitializer wires the integration initializer used by
|
|
// workspace creation. It is optional so the task service remains usable in
|
|
// deployments without GitHub.
|
|
func (s *Service) SetWorkspaceDefaultsInitializer(initializer WorkspaceDefaultsInitializer) {
|
|
s.workspaceDefaultsInitializer = initializer
|
|
}
|
|
|
|
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
|
|
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
|
|
s.workflowStepGetter = getter
|
|
}
|
|
|
|
// SetStartStepResolver wires the start step resolver for CreateTask.
|
|
func (s *Service) SetStartStepResolver(resolver StartStepResolver) {
|
|
s.startStepResolver = resolver
|
|
}
|
|
|
|
// SetStepHistoryRecorder wires the ADR 0015 audit-trail writer for manual
|
|
// step transitions (MoveTaskWithOptions). Optional.
|
|
func (s *Service) SetStepHistoryRecorder(recorder StepHistoryRecorder) {
|
|
s.stepHistoryRecorder = recorder
|
|
}
|
|
|
|
// SetContributionDestinationPreparer wires the optional server-side
|
|
// publication-route preparation used by managed Improve Kandev tasks.
|
|
func (s *Service) SetContributionDestinationPreparer(preparer ContributionDestinationPreparer) {
|
|
s.contributionDestinationPreparer = preparer
|
|
}
|
|
|
|
// SetPRTaskResolver wires the GitHub PR→task resolver for PR-number search.
|
|
// Optional — when unset, search by PR number is a no-op.
|
|
func (s *Service) SetPRTaskResolver(resolver PRTaskResolver) {
|
|
s.prTaskResolver = resolver
|
|
}
|
|
|
|
// SetQuickChatDir sets the directory for quick-chat workspaces.
|
|
// When set, task cleanup deletes the session directory under this path for all tasks.
|
|
func (s *Service) SetQuickChatDir(dir string) {
|
|
s.quickChatDir = dir
|
|
}
|
|
|
|
// RemoteBranchSource is the host-verified identity of a provider-backed
|
|
// workspace repository. Callers must derive it from persisted repository data.
|
|
type RemoteBranchSource struct {
|
|
WorkspaceID string
|
|
Provider string
|
|
ProviderHost string
|
|
ProviderScope string
|
|
ProviderRepositoryID string
|
|
Owner string
|
|
Name string
|
|
RemoteURL string
|
|
DefaultBranch string
|
|
}
|
|
|
|
// RemoteBranchLister fetches branches from a provider's remote API without
|
|
// needing a local clone. Used by ListBranches so a repo that is
|
|
// registered as remote ("Remote" badge in the UI) can serve branches before
|
|
// or even without the orchestrator finishing its clone.
|
|
type RemoteBranchLister interface {
|
|
ListRepoBranches(ctx context.Context, source RemoteBranchSource) ([]Branch, error)
|
|
}
|
|
|
|
// SetRemoteBranchLister wires the provider-neutral remote branch source.
|
|
func (s *Service) SetRemoteBranchLister(lister RemoteBranchLister) {
|
|
s.remoteBranchLister = lister
|
|
}
|
|
|
|
// SetRepositorySelectionResolver wires server-side inspection for first-use
|
|
// plugin repository selections. The resolver is optional for focused callers;
|
|
// plugin selections fail closed when it is not wired.
|
|
func (s *Service) SetRepositorySelectionResolver(resolver RepositorySelectionResolver) {
|
|
s.repositorySelectionResolver = resolver
|
|
}
|
|
|
|
// RepoCloneLocation reports the base path the orchestrator clones repos into
|
|
// (e.g. ~/.kandev/repos or KANDEV_REPOCLONE_BASEPATH). Listing local branches
|
|
// for a cloned repo requires that path to be allow-listed by
|
|
// discoveryRoots(); without this hook clones to a custom basepath silently
|
|
// fall outside the allow-list and branch listing returns no results.
|
|
type RepoCloneLocation interface {
|
|
ExpandedBasePath() (string, error)
|
|
}
|
|
|
|
// SetRepoCloneLocation wires the cloner so its base path is treated as an
|
|
// implicit discovery root.
|
|
func (s *Service) SetRepoCloneLocation(loc RepoCloneLocation) {
|
|
s.repoCloneLocation = loc
|
|
}
|