Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
158 lines
5.5 KiB
Go
158 lines
5.5 KiB
Go
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 <dir>` repo discovery and operates
|
|
// on the hook's repository instead — which is how worktree tests running under
|
|
// the push hook once committed stray "init" commits onto the real branch.
|
|
func gitEnvironmentWithoutRepoOverrides() []string {
|
|
scrubbed := make([]string, 0, len(os.Environ()))
|
|
for _, entry := range os.Environ() {
|
|
key, _, _ := strings.Cut(entry, "=")
|
|
switch key {
|
|
case "GIT_DIR",
|
|
"GIT_WORK_TREE",
|
|
"GIT_INDEX_FILE",
|
|
"GIT_COMMON_DIR",
|
|
"GIT_OBJECT_DIRECTORY",
|
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
"GIT_PREFIX":
|
|
continue
|
|
}
|
|
scrubbed = append(scrubbed, entry)
|
|
}
|
|
return scrubbed
|
|
}
|
|
|
|
func directoryIsGitRepo(dir string) bool {
|
|
if strings.TrimSpace(dir) == "" {
|
|
return false
|
|
}
|
|
// The repo-root marker is enough: linked worktrees keep .git as a file.
|
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// createIssueTaskRunWorktree adds a per-run git worktree (own branch, from the
|
|
// base checkout's current HEAD) under the daemon state directory. The worktree
|
|
// is left in place after the run so the user can inspect and merge the work.
|
|
func (s IssueManagerService) createIssueTaskRunWorktree(
|
|
ctx context.Context,
|
|
baseDir string,
|
|
issueID string,
|
|
taskID string,
|
|
runID string,
|
|
) (string, string, error) {
|
|
path, branch := s.issueTaskRunWorktreePlan(issueID, taskID, runID)
|
|
root := filepath.Dir(path)
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return "", "", fmt.Errorf("create task worktree root: %w", err)
|
|
}
|
|
worktreeCtx, cancel := context.WithTimeout(ctx, issueTaskWorktreeTimeout)
|
|
defer cancel()
|
|
command := exec.CommandContext(worktreeCtx, "git", "-C", baseDir, "worktree", "add", "-b", branch, path, "HEAD")
|
|
command.Env = gitEnvironmentWithoutRepoOverrides()
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
return "", "", fmt.Errorf("create task worktree: %v: %s", err, strings.TrimSpace(string(output)))
|
|
}
|
|
return path, branch, nil
|
|
}
|
|
|
|
func (s IssueManagerService) issueTaskRunWorktreePlan(issueID string, taskID string, runID string) (string, string) {
|
|
suffix := strings.NewReplacer("-", "", ":", "").Replace(runID)
|
|
if len(suffix) > 8 {
|
|
suffix = suffix[:8]
|
|
}
|
|
name := taskID + "-" + suffix
|
|
root := filepath.Join(s.taskWorktreeRoot(), issueID)
|
|
path := filepath.Join(root, name)
|
|
branch := "tutti/task/" + name
|
|
return path, branch
|
|
}
|