Files
kandev/apps/backend/internal/task/service/service_branches.go
T
João Salavisa be9821fc55 fix(task): surface add_branch_to_task failures instead of silent orphans (#1264)
* fix(worktree): promote FallbackBaseBranch when BaseBranch is empty

CreateRequest.Validate previously hard-rejected an empty BaseBranch even
when the caller supplied a FallbackBaseBranch. The fallback is plumbed
specifically so a missing primary branch can degrade into the repo's
default — this lets that path actually fire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task): probe provider default branch in add_branch resolve path

Adds an opt-in ProviderDefaultBranchProber seam (impl wired in cmd/kandev)
and threads a ResolveProviderDefaults flag through TaskRepositoryInput so
the add_branch_to_task GitHub-URL path can resolve a real default_branch
before writing the Repository row. Also backfills the existing repo's
default_branch when the probe succeeds and the workspace already had a row
with an empty value. The create_task path keeps its zero-default contract
(executor still backfills after clone).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): surface add_branch_to_task failures instead of silent orphans

AddBranchToTask used to return a success envelope even when the
post-insert worktree materialize step failed on a live task. The
task_repositories row stayed in place with no on-disk worktree, and
provider-URL flows also left an orphan repositories row behind.

Three coordinated changes:
- Gate empty base_branch after resolution with a typed error so no row
  is written when nothing can be materialized.
- Track whether the repository row was created by this call and roll it
  back on any subsequent failure.
- materializeBranch now returns its error; on a live worktree-executor
  task, AddBranchToTask rolls back the task_repositories row and surfaces
  the failure. Pre-launch tasks keep the legacy best-effort path so the
  next session launch still materializes via the multi-repo prepare flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(mcp): classify add_branch base_branch resolution failure as validation

AddBranchToTask now returns a "cannot resolve base_branch" error when the
provider repo's default branch could not be probed. Map it to
ErrorCodeValidation so MCP agents see a user-fixable input issue rather
than an opaque internal error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): defer task.updated until after worktree materializes

Publishing task.updated before materializeBranch ran meant a failed
materialize on a live task would push the new task_repository row to WS
clients and then silently roll it back, leaving stale UI state until the
next poll. Move the event after materialize success so the bus only sees
durable rows. Test now also asserts no task.updated fires on the rollback
path. Renames stubProber.lastProvid to lastProviderArg for clarity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): emit repository.deleted on add_branch orphan rollback

Cleanup callback previously called repoEntities.DeleteRepository directly,
which soft-deletes the row but bypasses Service.DeleteRepository's event
publish. The create path emitted repository.created, so skipping the
delete event left WS subscribers and frontend caches holding a phantom
row after a resolve-then-fail sequence. Route through the service method
so the events stay symmetric. Test now asserts the symmetry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): skip provider probe when workspace already has the repo

resolveRepoInput's add_branch probe fired on every add_branch_to_task
call with a github_url, even when the workspace already had the
provider repo registered with a non-empty default_branch. The existing
row's value would win seconds later via the repo.DefaultBranch fallback,
so the remote git ls-remote round-trip was pure waste — and unnecessarily
hit network on every call. Peek at the workspace registry first and
short-circuit when a usable default_branch is already on file.

Also adds a QA-tier test file covering probe-error fallthrough, bad URL
rejection, registered-repo probe skipping, empty-default backfill, and
the repository_id fast path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): detach rollback paths from caller ctx, log probe-lookup errors

Two adjacent robustness fixes flagged on PR #1264:

1. resolveRepoInput silently discarded the GetRepositoryByProviderInfo
   error before the provider probe. A transient DB failure was
   indistinguishable from a clean "not found" lookup, so the code would
   pay for a network probe instead of failing fast. Surface the error in
   logs and skip the probe — FindOrCreateRepository below already hits
   the same DB and will return the real cause.

2. The repository-create cleanup callback in resolveBranchAddRepoRef and
   the task_repositories rollback in AddBranchToTask both captured the
   caller ctx for their DELETE queries. Rollback often runs precisely
   because that ctx was cancelled (timeout, client disconnect), and
   reusing it would kill the cleanup and orphan the row we just created.
   Use context.WithoutCancel so the rollback survives ctx death.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): close orphan-row gaps from caller cancel and concurrent create

Two findings from Claude's review on PR #1264:

1. (blocker) materializeBranch called taskAlreadyLaunched(ctx, ...) after
   MaterializeBranch returned, using the same caller context that often
   failed the materialize in the first place. A cancelled ctx made the
   post-failure DB lookup return (nil, err), taskAlreadyLaunched returned
   false, and the live-task rollback path silently demoted itself to the
   pre-launch best-effort branch — leaving the orphan task_repositories
   row the PR exists to prevent. Snapshot the launch state BEFORE
   MaterializeBranch (launch state can't change during the synchronous
   call) so the rollback decision is anchored to a live-ctx answer.

2. (suggestion) resolveBranchAddRepoRef inferred "this call created the
   repo" by comparing the resolved ID against a workspace ListRepositories
   snapshot taken before FindOrCreateRepository. A concurrent add_branch
   for the same GitHub URL could create the row between snapshot and
   lookup; the second request would treat it as "not in my snapshot, must
   be mine" and register cleanup against the other request's data. On any
   later failure, cleanup would DELETE a repository this call didn't
   create and emit a spurious repository.deleted event.

   Thread an explicit `created bool` out of CreateRepository through
   FindOrCreateRepository → resolveRepoInput → ResolveRepositoryRef so
   the cleanup callback fires only when this call physically inserted
   the row. The flag is set at the CreateRepository / FindOrCreateRepository
   sites themselves and cannot be spoofed by concurrent activity.

resolveRepoInput's branches were extracted into resolveRepoInputGitHub /
resolveRepoInputLocal helpers to keep the parent function inside the
cyclomatic-complexity and cognitive-complexity budgets after the signature
change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: retrigger cubic CI

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:43:41 +01:00

522 lines
22 KiB
Go

package service
import (
"context"
"fmt"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/worktree"
)
// AddBranchToTaskRequest carries the parameters for adding a new branch
// (worktree) to an existing task. The (RepositoryID, CheckoutBranch) pair
// must not already exist on the task.
//
// RepositoryID, LocalPath, and GitHubURL are alternative ways to identify the
// target repository — mirrors the create_task input shape. When RepositoryID
// is empty and either LocalPath or GitHubURL is set, the service resolves to
// (or creates) the matching repository in the task's workspace. Pure
// RepositoryID is the fast path; the other two are agent-ergonomic
// alternatives so callers don't have to look up the UUID first.
type AddBranchToTaskRequest struct {
TaskID string
RepositoryID string
LocalPath string
GitHubURL string
BaseBranch string
CheckoutBranch string
}
// AddBranchToTask appends a new task_repositories row to an existing task,
// effectively adding a branch (or a second branch of an already-attached repo)
// without recreating the task. The row's position is set to the next free
// slot after the highest existing position.
//
// Returns the persisted TaskRepository on success.
//
// Constraints:
// - TaskID is required.
// - RepositoryID, LocalPath, and GitHubURL are alternative ways to identify
// the target repository. All three are optional: when none is supplied
// the service defaults to the task's existing repository (the only one
// for single-repo tasks; the primary by lowest position otherwise).
// Multi-repo tasks must pass one explicitly — defaulting would be
// ambiguous. LocalPath and GitHubURL are resolved (find-or-create)
// through the same workspace-scoped path used by create_task.
// - The (TaskID, RepositoryID, BaseBranch, CheckoutBranch) tuple must be
// unique on the task — duplicates surface as a typed error so callers
// can render a user-friendly message instead of a raw DB error.
// - The repository must belong to the task's workspace. The resolution
// path (ResolveRepositoryRef → resolveRepoInput) enforces this for the
// LocalPath / GitHubURL flows; for the RepositoryID fast path the
// workspace-membership check happens in resolveBranchRepo below.
func (s *Service) AddBranchToTask(ctx context.Context, req AddBranchToTaskRequest) (*models.TaskRepository, error) {
task, existing, cleanupOrphanRepo, err := s.prepareBranchAdd(ctx, &req)
if err != nil {
return nil, err
}
repo, err := s.resolveBranchRepo(ctx, task, req.RepositoryID)
if err != nil {
cleanupOrphanRepo()
return nil, err
}
baseBranch := req.BaseBranch
if baseBranch == "" {
baseBranch = repo.DefaultBranch
}
// Gate before any task_repositories write: with no resolvable base branch
// the materialize step can only fail, leaving an orphan row + (worse) an
// orphan provider repository_url Repository row. Fail loud so the MCP
// caller can supply base_branch explicitly or pre-register the repo.
if baseBranch == "" {
cleanupOrphanRepo()
return nil, fmt.Errorf("cannot resolve base_branch for repository %q: pass base_branch explicitly", repoLabelOrID(repo, req.RepositoryID))
}
checkoutBranch := resolveCheckoutBranchOrAutoName(req.CheckoutBranch, existing, req.RepositoryID, baseBranch)
nextPosition, dupErr := scanForBranchAddDuplicate(existing, req.RepositoryID, baseBranch, checkoutBranch, repo)
if dupErr != nil {
cleanupOrphanRepo()
return nil, dupErr
}
taskRepo := &models.TaskRepository{
TaskID: req.TaskID,
RepositoryID: req.RepositoryID,
BaseBranch: baseBranch,
CheckoutBranch: checkoutBranch,
Position: nextPosition,
Metadata: map[string]interface{}{},
}
if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil {
s.logger.Error("AddBranchToTask: failed to create task repository",
zap.String("task_id", req.TaskID),
zap.String("repository_id", req.RepositoryID),
zap.String("checkout_branch", req.CheckoutBranch),
zap.Error(err))
cleanupOrphanRepo()
return nil, fmt.Errorf("create task repository: %w", err)
}
if err := s.materializeBranch(ctx, req.TaskID, taskRepo.ID); err != nil {
// Roll back the task_repositories row so a failed materialize doesn't
// leave a dangling association the user can't see on disk. Pre-launch
// tasks short-circuit inside materializeBranch and never reach this
// branch — their worktree is built at next session launch.
//
// Detach from ctx via WithoutCancel: a caller-side timeout or cancel
// can fire mid-materialize, and the rollback must still run on the
// now-dead ctx or we'd leak the row we tried to delete.
rollbackCtx := context.WithoutCancel(ctx)
if delErr := s.taskRepos.DeleteTaskRepository(rollbackCtx, taskRepo.ID); delErr != nil {
s.logger.Error("AddBranchToTask: failed to roll back task repository after materialize failure",
zap.String("task_repository_id", taskRepo.ID),
zap.Error(delErr))
}
cleanupOrphanRepo()
return nil, err
}
// Publish task.updated only after the row is durable AND the worktree
// materialized. Emitting before materialize would push a phantom row to
// WS clients on the rollback path; emitting after keeps event truthiness
// aligned with persisted state.
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
return taskRepo, nil
}
// prepareBranchAdd runs the input validation, executor-shield check, and
// repository_id defaulting that AddBranchToTask needs before it can pick a
// branch name and write the row. Returns the loaded task plus the current
// task_repositories list so the caller doesn't re-query.
//
// Mutates req.RepositoryID when it was omitted and a single-repo default
// applies; multi-repo tasks return an error here instead of silently
// guessing the wrong repo.
//
// Also returns a cleanupOrphanRepo callback that the caller invokes on any
// error path AFTER prepareBranchAdd returns successfully: when this call
// created a fresh Repository row via the GitHubURL/LocalPath
// find-or-create path, the callback deletes it so a later validation or
// materialize failure does not leave an orphan provider row in the DB. The
// callback is never nil and is safe to call on the happy path (no-op when
// nothing was created here).
func (s *Service) prepareBranchAdd(ctx context.Context, req *AddBranchToTaskRequest) (*models.Task, []*models.TaskRepository, func(), error) {
noopCleanup := func() {}
if req.TaskID == "" {
return nil, nil, noopCleanup, fmt.Errorf("task_id is required")
}
task, err := s.tasks.GetTask(ctx, req.TaskID)
if err != nil {
return nil, nil, noopCleanup, fmt.Errorf("get task: %w", err)
}
if task == nil {
// Wrap the repo-tier sentinel so handlers can classify via errors.Is
// rather than substring-matching the formatted UUID.
return nil, nil, noopCleanup, fmt.Errorf("%w: %s", taskrepo.ErrTaskNotFound, req.TaskID)
}
// Executor gate runs BEFORE repository resolution so a rejection on a
// non-worktree executor can't leak an orphan Repository row into the
// workspace via FindOrCreateRepository / CreateRepository inside
// ResolveRepositoryRef. Mirrors the "keeps the DB clean" invariant
// documented on requireWorktreeExecutorForBranchAdd itself.
if err := s.requireWorktreeExecutorForBranchAdd(ctx, req.TaskID); err != nil {
return nil, nil, noopCleanup, err
}
cleanupOrphanRepo, err := s.resolveBranchAddRepoRef(ctx, task, req, noopCleanup)
if err != nil {
return nil, nil, noopCleanup, err
}
existing, err := s.taskRepos.ListTaskRepositories(ctx, req.TaskID)
if err != nil {
cleanupOrphanRepo()
return nil, nil, noopCleanup, fmt.Errorf("list existing branches: %w", err)
}
if req.RepositoryID == "" {
req.RepositoryID, err = s.defaultRepositoryIDForTask(existing)
if err != nil {
cleanupOrphanRepo()
return nil, nil, noopCleanup, err
}
}
return task, existing, cleanupOrphanRepo, nil
}
// resolveBranchRepo loads the Repository for req.RepositoryID and verifies
// it belongs to the task's workspace. Extracted from AddBranchToTask so the
// repository / workspace check stays separated from the row-uniqueness
// logic for the linter's complexity budget.
func (s *Service) resolveBranchRepo(ctx context.Context, task *models.Task, repositoryID string) (*models.Repository, error) {
repo, err := s.repoEntities.GetRepository(ctx, repositoryID)
if err != nil {
return nil, fmt.Errorf("get repository: %w", err)
}
if repo == nil || repo.WorkspaceID != task.WorkspaceID {
return nil, fmt.Errorf("repository %q does not belong to task's workspace", repositoryID)
}
return repo, nil
}
// resolveCheckoutBranchOrAutoName picks the branch name for the new
// task_repositories row. When the caller passed an explicit value it's
// taken verbatim; when omitted AND a row for (repo, base, "") already
// exists, the helper auto-names the new row branch-N so add_branch is
// ergonomic for agents that only express intent ("make a new branch").
func resolveCheckoutBranchOrAutoName(
requested string,
existing []*models.TaskRepository,
repositoryID, baseBranch string,
) string {
if requested != "" {
return requested
}
if hasRowForRepoBase(existing, repositoryID, baseBranch) {
return autoBranchName(existing, repositoryID, baseBranch)
}
return ""
}
// scanForBranchAddDuplicate walks the existing task_repositories rows once
// to compute the next position AND surface a typed duplicate error when
// the prospective (repo, base, checkout) tuple already exists. The single
// pass keeps the hot AddBranchToTask path compact and the linter happy
// (the inline version pushed the parent over the cyclop budget).
func scanForBranchAddDuplicate(
existing []*models.TaskRepository,
repositoryID, baseBranch, checkoutBranch string,
repo *models.Repository,
) (nextPosition int, dupErr error) {
// Branches whose names differ only in unsafe characters (e.g. "feat/a" vs
// "feat-a") sanitize to the same on-disk slug and would collide at
// `git worktree add`. SanitizeBranchSlug documents that the service layer
// must reject those duplicates — the exact-string check below would let
// both rows land in task_repositories.
newSlug := worktree.SanitizeBranchSlug(checkoutBranch)
for _, tr := range existing {
if tr.RepositoryID == repositoryID &&
tr.BaseBranch == baseBranch &&
tr.CheckoutBranch == checkoutBranch {
return 0, branchAddDuplicateError(repo, repositoryID, baseBranch, checkoutBranch)
}
// Slug-collision check is NOT scoped by base_branch — worktree path
// derivation (TaskWorktreePath) uses only repo + slug, so two rows
// on the same repo with sibling slugs would land on the same on-disk
// directory regardless of which base_branch they were cut from.
if tr.RepositoryID == repositoryID &&
checkoutBranch != "" && tr.CheckoutBranch != "" &&
tr.CheckoutBranch != checkoutBranch &&
worktree.SanitizeBranchSlug(tr.CheckoutBranch) == newSlug && newSlug != "" {
return 0, fmt.Errorf(
"checkout branch %q conflicts with existing branch %q on this task (both resolve to the same worktree path)",
checkoutBranch, tr.CheckoutBranch,
)
}
if tr.Position >= nextPosition {
nextPosition = tr.Position + 1
}
}
return nextPosition, nil
}
// branchAddDuplicateError returns the user-facing duplicate-attachment
// error, preferring the more descriptive "on branch %q" form when a branch
// name is available. Extracted so the scan loop in
// scanForBranchAddDuplicate stays single-purpose.
func branchAddDuplicateError(
repo *models.Repository, repositoryID, baseBranch, checkoutBranch string,
) error {
label := repoLabelOrID(repo, repositoryID)
branchLabel := checkoutBranch
if branchLabel == "" {
branchLabel = baseBranch
}
if branchLabel == "" {
return fmt.Errorf("repository %q is already attached to this task", label)
}
return fmt.Errorf("repository %q on branch %q is already attached to this task", label, branchLabel)
}
// materializeBranch triggers the worktree manager + agentctl rescan.
//
// Pre-launch tasks (no task_environments row, or no materializer wired) keep
// the legacy best-effort behaviour: failures are logged and the next session
// launch will pick the worktree up via prepareMultiRepo. Returns nil in both
// of those cases so the task_repositories row sticks around for the launcher
// to act on.
//
// Live worktree-executor tasks (already launched) surface the materialize
// failure to the caller so AddBranchToTask can roll back the dangling
// task_repositories row — leaving it in place produced the silent-success
// orphan that the original bug report describes.
func (s *Service) materializeBranch(ctx context.Context, taskID, taskRepositoryID string) error {
if s.branchMaterializer == nil {
return nil
}
// Snapshot launch state BEFORE MaterializeBranch so a caller-side cancel
// during materialize can't poison the post-failure check: launch state
// cannot change during the synchronous materialize call, but the DB
// query in taskAlreadyLaunched would fail with a context error if we
// asked after the fact — and that would silently demote a live-task
// rollback to a best-effort no-op, leaving the orphan row the PR exists
// to prevent.
alreadyLaunched := s.taskAlreadyLaunched(ctx, taskID)
if err := s.branchMaterializer.MaterializeBranch(ctx, taskID, taskRepositoryID); err != nil {
if !alreadyLaunched {
s.logger.Warn("branch materialization failed; worktree will be created on next session launch",
zap.String("task_id", taskID),
zap.String("task_repository_id", taskRepositoryID),
zap.Error(err))
return nil
}
s.logger.Error("branch materialization failed on a live task; rolling back",
zap.String("task_id", taskID),
zap.String("task_repository_id", taskRepositoryID),
zap.Error(err))
return fmt.Errorf("materialize branch: %w", err)
}
return nil
}
// taskAlreadyLaunched reports whether the task already has a task_environments
// row whose executor type is fixed — the signal that an executor backend is
// running and won't replay prepareMultiRepo for the new branch on its own.
// Pre-launch tasks (no row, or no executor type yet) return false so a
// materialize failure stays best-effort.
func (s *Service) taskAlreadyLaunched(ctx context.Context, taskID string) bool {
if s.taskEnvironments == nil {
return false
}
env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID)
if err != nil || env == nil {
return false
}
return env.ExecutorType != ""
}
// defaultRepositoryIDForTask resolves the implicit repository to use when
// AddBranchToTask is called without one. Single-repo tasks resolve trivially;
// multi-repo tasks force the caller to disambiguate so we never silently
// pick the wrong repo. Returns an error rather than guessing in ambiguous
// cases so the agent sees a clear "specify repository_id" message.
func (s *Service) defaultRepositoryIDForTask(existing []*models.TaskRepository) (string, error) {
if len(existing) == 0 {
return "", fmt.Errorf("repository_id is required: task has no repositories attached yet")
}
uniqueRepos := make(map[string]bool, len(existing))
for _, tr := range existing {
uniqueRepos[tr.RepositoryID] = true
}
if len(uniqueRepos) > 1 {
return "", fmt.Errorf("repository_id is required: task has %d repositories — pick one explicitly", len(uniqueRepos))
}
// Single repo (possibly with multiple branches already attached). Return
// the primary (lowest-position) row's repo id — they all share the same
// repository_id by definition of the uniqueness check above.
primary := existing[0]
for _, tr := range existing {
if tr.Position < primary.Position {
primary = tr
}
}
return primary.RepositoryID, nil
}
// requireWorktreeExecutorForBranchAdd rejects add_branch_to_task calls on
// tasks whose execution environment isn't the worktree executor. Sibling
// worktrees are a git-worktree-specific layout — containerized executors
// (docker, sprites, ssh) bind one workspace path per task and would
// silently ignore the new sibling. Returning early here also keeps the
// DB clean: without the guard, the row lands in task_repositories but
// nothing materializes on disk, leaving an orphan that breaks invariants.
//
// Tasks that haven't launched yet (no TaskEnvironment row) are permitted:
// the executor type isn't fixed yet, and rejecting them would block the
// pre-launch "set up multi-branch first, then start the agent" flow.
func (s *Service) requireWorktreeExecutorForBranchAdd(ctx context.Context, taskID string) error {
if s.taskEnvironments == nil {
return nil
}
env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID)
if err != nil {
return fmt.Errorf("look up task environment: %w", err)
}
if env == nil || env.ExecutorType == "" {
return nil
}
if env.ExecutorType != string(models.ExecutorTypeWorktree) {
return fmt.Errorf(
"add_branch_to_task is only supported on the worktree executor; this task uses %q",
env.ExecutorType,
)
}
return nil
}
// hasRowForRepoBase reports whether `existing` already contains a row for
// the (repo, base, "") triple — i.e. an attached worktree on the same base
// branch with no explicit checkout_branch. Used to decide whether an
// otherwise-empty checkout_branch should be auto-named so the new row
// doesn't collide.
func hasRowForRepoBase(existing []*models.TaskRepository, repositoryID, baseBranch string) bool {
for _, tr := range existing {
if tr.RepositoryID == repositoryID && tr.BaseBranch == baseBranch && tr.CheckoutBranch == "" {
return true
}
}
return false
}
// autoBranchName picks a fresh `branch-<random>` name for a new
// task_repositories row when the caller didn't specify checkout_branch.
// Uses a short random suffix instead of a sequential counter so the name
// is unpredictable — predictable names like `branch-2` invite collisions
// when concurrent agents add branches simultaneously and leak ordering
// information into the worktree path on disk.
//
// On the astronomically unlikely event of a collision with an existing
// row for the same (repo, base), retry up to a few times before giving
// up and letting the caller surface the duplicate-name error.
//
// The naming intentionally avoids encoding the base branch name to keep
// the slug-derived directory short and stable across base renames.
func autoBranchName(existing []*models.TaskRepository, repositoryID, baseBranch string) string {
for range 5 {
candidate := "branch-" + worktree.SmallSuffix(3)
if !branchNameExistsForRepoBase(existing, repositoryID, baseBranch, candidate) {
return candidate
}
}
return "branch-" + worktree.SmallSuffix(3)
}
// branchNameExistsForRepoBase reports whether `existing` already has a row
// for the given (repo, base, checkout) triple — used by autoBranchName to
// reject random candidates that collide with prior rows.
func branchNameExistsForRepoBase(existing []*models.TaskRepository, repositoryID, baseBranch, checkoutBranch string) bool {
for _, tr := range existing {
if tr.RepositoryID == repositoryID && tr.BaseBranch == baseBranch && tr.CheckoutBranch == checkoutBranch {
return true
}
}
return false
}
// repoLabelOrID returns a human-readable repository label for error messages,
// preferring "owner/name" then bare name, falling back to the raw UUID.
func repoLabelOrID(repo *models.Repository, repositoryID string) string {
if repo == nil {
return repositoryID
}
if repo.ProviderOwner != "" && repo.ProviderName != "" {
return repo.ProviderOwner + "/" + repo.ProviderName
}
if repo.Name != "" {
return repo.Name
}
return repositoryID
}
// resolveBranchAddRepoRef resolves LocalPath / GitHubURL on the add_branch
// request to a concrete RepositoryID up front so the rest of the flow
// (duplicate scan, row insert) operates on a stable UUID. Returns a cleanup
// callback that the caller must invoke on any error path between resolution
// and a successful task_repositories insert — when the call created a fresh
// Repository row, the callback deletes it so workspace state doesn't accrue
// dangling provider rows. Pure no-op when RepositoryID was already set or
// neither alternative identifier was supplied.
func (s *Service) resolveBranchAddRepoRef(
ctx context.Context, task *models.Task, req *AddBranchToTaskRequest, noopCleanup func(),
) (func(), error) {
if req.RepositoryID != "" || (req.LocalPath == "" && req.GitHubURL == "") {
return noopCleanup, nil
}
resolvedID, resolvedBranch, createdByUs, resolveErr := s.ResolveRepositoryRef(ctx, task.WorkspaceID, TaskRepositoryInput{
LocalPath: req.LocalPath,
GitHubURL: req.GitHubURL,
BaseBranch: req.BaseBranch,
ResolveProviderDefaults: true,
})
if resolveErr != nil {
return noopCleanup, fmt.Errorf("resolve repository: %w", resolveErr)
}
req.RepositoryID = resolvedID
if req.BaseBranch == "" {
req.BaseBranch = resolvedBranch
}
// Register cleanup only when this call actually inserted the Repository
// row. The previous heuristic (workspace pre-list snapshot vs resolvedID)
// had a TOCTOU race: a concurrent add_branch for the same GitHub URL
// could create the row between the snapshot and FindOrCreateRepository,
// then we'd register cleanup against another request's data and delete
// it on any later failure here. createdByUs is set by FindOrCreateRepository
// / CreateRepository themselves and can't be spoofed by concurrency.
if resolvedID == "" || !createdByUs {
return noopCleanup, nil
}
createdID := resolvedID
cleanup := func() {
// Route through Service.DeleteRepository so the matching
// repository.deleted event fires; the create path published
// repository.created in CreateRepository, and skipping the
// delete event would leave WS subscribers / frontend caches
// holding a phantom row.
//
// Detach from the captured ctx via WithoutCancel: the rollback
// often runs precisely because the caller's ctx was cancelled
// (timeout, client disconnect), and reusing it would also kill
// the cleanup query and orphan the row we just created.
rollbackCtx := context.WithoutCancel(ctx)
if delErr := s.DeleteRepository(rollbackCtx, createdID); delErr != nil {
s.logger.Warn("AddBranchToTask: failed to roll back created repository",
zap.String("repository_id", createdID),
zap.Error(delErr))
}
}
return cleanup, nil
}