Files
tutti/services/tuttid/service/workspace/issue_task_worktree.go
T
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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
}