Files

216 lines
7.1 KiB
Go

package worktree
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"go.uber.org/zap"
)
const replacementBranchAttempts = 20
// replaceUnrecoverableWorktree creates a fresh branch and worktree while
// retaining the existing task-environment repository record. The caller holds
// the repository lock from recreate, so this helper must not acquire it again.
func (m *Manager) replaceUnrecoverableWorktree(
ctx context.Context,
existing *Worktree,
req CreateRequest,
branchErr *BranchUnrecoverableError,
) (*Worktree, error) {
if existing == nil {
return nil, branchErr
}
replacementReq, err := replacementRequest(existing, req)
if err != nil {
return nil, err
}
baseRef, fallbackWarning, fallbackDetail, err := m.resolveBaseRefWithFallback(ctx, &replacementReq)
if err != nil {
return nil, fmt.Errorf("resolve replacement base branch: %w", err)
}
branchName, worktreePath, err := m.replacementTarget(ctx, replacementReq)
if err != nil {
return nil, err
}
if _, err := m.gitAddWorktree(ctx, replacementReq.RepositoryPath, branchName, worktreePath, baseRef); err != nil {
return nil, fmt.Errorf("create replacement worktree for %q: %w", branchErr.BranchName(), err)
}
replacement := replacementRecord(existing, replacementReq, branchName, worktreePath, fallbackWarning, fallbackDetail)
if err := m.persistReplacementWorktree(ctx, &replacement); err != nil {
if cleanupErr := m.cleanupFailedReplacement(ctx, &replacement); cleanupErr != nil {
return nil, errors.Join(err, fmt.Errorf("cleanup replacement after persistence failure: %w", cleanupErr))
}
return nil, err
}
m.copyConfiguredFiles(ctx, replacementReq, &replacement)
m.runWorktreeSetupScript(ctx, &replacement, replacementReq.ScriptEnv)
m.logger.Info("replaced unrecoverable worktree branch",
zap.String("session_id", replacement.SessionID),
zap.String("task_id", replacement.TaskID),
zap.String("original_branch", branchErr.BranchName()),
zap.String("new_branch", replacement.Branch),
zap.String("path", replacement.Path))
return &replacement, nil
}
// cleanupFailedReplacement removes the physical checkout and the branch that
// gitAddWorktree created before the replacement record could be persisted. The
// original record remains authoritative when this compensation runs.
func (m *Manager) cleanupFailedReplacement(ctx context.Context, replacement *Worktree) error {
if replacement == nil {
return nil
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), m.inspectTimeout)
defer cancel()
var cleanupErrs []error
if err := m.removeWorktreeDir(cleanupCtx, replacement.Path, replacement.RepositoryPath); err != nil {
cleanupErrs = append(cleanupErrs, fmt.Errorf("remove replacement worktree: %w", err))
}
if err := m.cleanupFailedReplacementBranch(cleanupCtx, replacement); err != nil {
cleanupErrs = append(cleanupErrs, err)
}
return errors.Join(cleanupErrs...)
}
func (m *Manager) cleanupFailedReplacementBranch(ctx context.Context, replacement *Worktree) error {
branchRef := "refs/heads/" + replacement.Branch
exists, err := m.branchExists(ctx, replacement.RepositoryPath, branchRef)
if err != nil {
return fmt.Errorf("verify replacement branch: %w", err)
}
if !exists {
return nil
}
output, err := m.runBoundedGitInspect(
ctx,
replacement.RepositoryPath,
"rev-parse",
"--verify",
branchRef+"^{commit}",
)
if err != nil {
return fmt.Errorf("resolve replacement branch: %w", err)
}
_, err = deleteBranchRefIfOwned(ctx, replacement.RepositoryPath, newBranchAddSnapshot{
branchRef: branchRef,
branchOID: strings.TrimSpace(output),
})
if err != nil {
return fmt.Errorf("delete replacement branch: %w", err)
}
return nil
}
func replacementRequest(existing *Worktree, req CreateRequest) (CreateRequest, error) {
replacementReq := req
if replacementReq.BaseBranch == "" {
replacementReq.BaseBranch = existing.BaseBranch
}
if replacementReq.TaskDirName == "" {
replacementReq.TaskDirName = existing.TaskDirName
}
if replacementReq.TaskEnvironmentID == "" {
replacementReq.TaskEnvironmentID = existing.TaskEnvironmentID
}
if replacementReq.BaseBranch == "" || replacementReq.TaskDirName == "" || replacementReq.RepoName == "" {
return CreateRequest{}, fmt.Errorf("replace unrecoverable worktree: %w", ErrTaskDirRequired)
}
return replacementReq, nil
}
func replacementRecord(
existing *Worktree,
req CreateRequest,
branchName, worktreePath, fallbackWarning, fallbackDetail string,
) Worktree {
identitySlug := existing.BranchSlug
if identitySlug == "" {
identitySlug = requestBranchIdentitySlug(req)
}
replacement := *existing
replacement.TaskDirName = req.TaskDirName
replacement.TaskEnvironmentID = req.TaskEnvironmentID
replacement.BranchSlug = identitySlug
replacement.RepositoryPath = req.RepositoryPath
replacement.Path = worktreePath
replacement.Branch = branchName
replacement.BaseBranch = req.BaseBranch
replacement.Status = StatusActive
replacement.DeletedAt = nil
replacement.FetchWarning = ""
replacement.FetchWarningDetail = ""
replacement.BaseBranchFallbackWarning = fallbackWarning
replacement.BaseBranchFallbackDetail = fallbackDetail
replacement.UpdatedAt = time.Now()
if replacement.SessionID == "" {
replacement.SessionID = req.SessionID
}
if replacement.TaskID == "" {
replacement.TaskID = req.TaskID
}
return replacement
}
func (m *Manager) persistReplacementWorktree(ctx context.Context, replacement *Worktree) error {
if m.store != nil {
if err := m.store.UpdateWorktree(ctx, replacement); err != nil {
return fmt.Errorf("persist replacement worktree: %w", err)
}
}
if replacement.SessionID != "" {
m.mu.Lock()
m.worktrees[cacheKey(replacement.SessionID, replacement.RepositoryID, replacement.BranchSlug)] = replacement
m.mu.Unlock()
}
return nil
}
func (m *Manager) replacementTarget(ctx context.Context, req CreateRequest) (string, string, error) {
_, branchBase := m.buildWorktreeNames(req)
for attempt := 0; attempt < replacementBranchAttempts; attempt++ {
branchName := replacementBranchName(branchBase, attempt)
exists, err := m.branchExists(ctx, req.RepositoryPath, branchName)
if err != nil {
return "", "", fmt.Errorf("check replacement branch %q: %w", branchName, err)
}
if exists {
continue
}
path, err := m.replacementPath(req, branchName)
if err != nil {
return "", "", err
}
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
return branchName, path, nil
} else if err != nil {
return "", "", fmt.Errorf("inspect replacement worktree path: %w", err)
}
}
return "", "", fmt.Errorf("unable to find a unique replacement branch after %d attempts: %w", replacementBranchAttempts, ErrBranchExists)
}
func replacementBranchName(branchBase string, attempt int) string {
if attempt == 0 {
return branchBase
}
return branchBase + "-" + SmallSuffix(3)
}
func (m *Manager) replacementPath(req CreateRequest, branchName string) (string, error) {
pathReq := req
pathReq.BranchSlug = SanitizeBranchSlug(branchName)
pathReq.BranchIdentitySlug = requestBranchIdentitySlug(req)
path, err := m.prepareTaskWorktreePath(pathReq)
if err != nil {
return "", fmt.Errorf("prepare replacement worktree path: %w", err)
}
return path, nil
}