package workspace import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" workspaceissues "github.com/tutti-os/tutti/packages/workspace/issues" tuttitypes "github.com/tutti-os/tutti/services/tuttid/types" ) const issueTaskWorktreeTimeout = 30 * time.Second // issueTaskIsolation describes how a concurrently dispatched task is kept from // trampling siblings. An empty value means the task runs directly in its // resolved execution directory (exclusive task, or a directory it does not // share with any sibling). type issueTaskIsolation struct { // worktreeBase is the shared git checkout to isolate from; when set the // launcher creates a per-run worktree and runs the session inside it. worktreeBase string } func (s IssueManagerService) taskWorktreeRoot() string { if root := strings.TrimSpace(s.TaskWorktreeRoot); root != "" { return root } return filepath.Join(tuttitypes.DefaultStateDir(), "task-worktrees") } // resolveIssueTaskBaseDirectory mirrors the launch-time execution directory // resolution: the task's explicit directory wins, otherwise the planning // session's working directory is inherited. func resolveIssueTaskBaseDirectory(task workspaceissues.Task, sourceContext IssueSourceSessionContext) string { if explicit := strings.TrimSpace(task.ExecutionDirectory); explicit != "" { return explicit } return strings.TrimSpace(sourceContext.WorkingDirectory) } func (s IssueManagerService) resolveIssueSourceSessionContext(issue workspaceissues.Issue) (IssueSourceSessionContext, bool) { if s.SourceSessionContextResolver == nil || strings.TrimSpace(issue.SourceSessionID) == "" { return IssueSourceSessionContext{}, false } return s.SourceSessionContextResolver.ResolveSourceSessionContext(issue.WorkspaceID, issue.SourceSessionID) } // sequentialTaskIsolation decides whether a parallelizable task may actually // run alongside siblings and how. A task with an execution directory no // sibling uses needs no isolation; a shared git checkout gets a per-run // worktree; anything else (shared non-git directory, unresolvable base) // degrades to exclusive dispatch so concurrent sessions can never trample // each other. func sequentialTaskIsolation( tasks []workspaceissues.Task, task workspaceissues.Task, sourceContext IssueSourceSessionContext, ) (issueTaskIsolation, bool) { explicit := strings.TrimSpace(task.ExecutionDirectory) if explicit != "" && filepath.IsAbs(explicit) { shared := false for _, sibling := range tasks { if sibling.TaskID == task.TaskID { continue } if filepath.Clean(strings.TrimSpace(sibling.ExecutionDirectory)) == filepath.Clean(explicit) { shared = true break } } if !shared { return issueTaskIsolation{}, true } } base := resolveIssueTaskBaseDirectory(task, sourceContext) if base == "" || !directoryIsGitRepo(base) { return issueTaskIsolation{}, false } return issueTaskIsolation{worktreeBase: base}, true } // gitEnvironmentWithoutRepoOverrides returns the process environment minus // git's repository-location overrides. Git hooks (pre-push and friends) export // GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE to their own repository; a subprocess // that inherits them silently ignores `-C