Files
kandev/apps/backend/internal/worktree/worktree.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

242 lines
9.7 KiB
Go

package worktree
import "time"
// SyncProgressStatus represents the status of a base-branch sync progress event.
type SyncProgressStatus string
const (
SyncProgressRunning SyncProgressStatus = "running"
SyncProgressCompleted SyncProgressStatus = "completed"
)
// SyncProgressEvent reports pre-worktree base-branch synchronization progress.
type SyncProgressEvent struct {
StepName string
Status SyncProgressStatus
Output string
Error string
}
// SyncProgressCallback is called when base-branch sync status changes.
type SyncProgressCallback func(event SyncProgressEvent)
// Worktree represents a Git worktree associated with a task.
type Worktree struct {
// ID is the unique identifier for this worktree record.
ID string `json:"id"`
// SessionID is the task session associated with this worktree.
SessionID string `json:"session_id,omitempty"`
// TaskID is the ID of the task this worktree is associated with.
// Multiple worktrees can exist for the same task (one per agent session).
TaskID string `json:"task_id"`
// RepositoryID is the ID of the repository this worktree belongs to.
RepositoryID string `json:"repository_id"`
// BranchSlug, when set, disambiguates two worktrees that share a
// (SessionID, RepositoryID) pair on different branches. Stored on
// task_session_worktrees so reuse lookups can scope by branch and not
// collapse multi-branch tasks down to a single worktree.
BranchSlug string `json:"branch_slug,omitempty"`
// RepositoryPath is the local filesystem path to the main repository.
// Stored for recreation if the worktree directory is lost.
RepositoryPath string `json:"repository_path"`
// Path is the absolute filesystem path to the worktree directory.
Path string `json:"path"`
// Branch is the Git branch name checked out in this worktree.
Branch string `json:"branch"`
// BaseBranch is the branch this worktree was created from.
BaseBranch string `json:"base_branch"`
// Status indicates the current state of the worktree.
// Valid values: active, merged, deleted
Status string `json:"status"`
// CreatedAt is when this worktree was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is when this worktree was last modified.
UpdatedAt time.Time `json:"updated_at"`
// MergedAt is when this worktree's branch was merged (if applicable).
MergedAt *time.Time `json:"merged_at,omitempty"`
// DeletedAt is when this worktree was deleted (if applicable).
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// FetchWarning is a non-fatal warning from fetching the checkout branch.
// Set when fetch from origin failed but a local branch was used as fallback.
FetchWarning string `json:"fetch_warning,omitempty"`
// FetchWarningDetail contains the raw git command output for debugging.
// Shown as collapsible content alongside the user-friendly FetchWarning.
FetchWarningDetail string `json:"fetch_warning_detail,omitempty"`
// BaseBranchFallbackWarning is set when the requested BaseBranch did not
// exist in the repository and the worktree was created from a fallback
// branch (typically the repository's default_branch) instead. Empty when
// the original BaseBranch was used.
BaseBranchFallbackWarning string `json:"base_branch_fallback_warning,omitempty"`
// BaseBranchFallbackDetail mirrors FetchWarningDetail: a longer message
// describing why the original branch was not used. Surfaced as collapsible
// detail alongside BaseBranchFallbackWarning.
BaseBranchFallbackDetail string `json:"base_branch_fallback_detail,omitempty"`
// SetupScriptWarning is set when the repository's setup script failed.
// Setup script failures are non-fatal: the worktree is kept and the agent
// launches normally, but the failure is surfaced as a warning so the user
// can fix it. Empty when the script succeeded or no script was configured.
SetupScriptWarning string `json:"setup_script_warning,omitempty"`
// SetupScriptWarningDetail mirrors FetchWarningDetail: a longer message
// describing the setup-script failure. Surfaced as collapsible detail
// alongside SetupScriptWarning.
SetupScriptWarningDetail string `json:"setup_script_warning_detail,omitempty"`
// CopiedFiles lists the relative paths of files copied from the source
// repo into this worktree per the repository's CopyFiles spec. Populated
// only on the in-memory record returned by Create — not persisted, since
// it describes a one-shot launch action rather than worktree state. The
// env preparer reads it to surface a "Copy ignored files" prepare step.
CopiedFiles []string `json:"-"`
// CopyFilesWarnings lists non-fatal warnings emitted while copying files
// (e.g. missing patterns, traversal-rejected paths). Like CopiedFiles,
// this is in-memory only and read by the env preparer.
CopyFilesWarnings []string `json:"-"`
}
// CreateRequest contains the parameters for creating a new worktree.
type CreateRequest struct {
// TaskID is the unique task identifier (required).
TaskID string
// SessionID is the task session identifier (required for persistence).
SessionID string
// TaskTitle is the human-readable task title (optional).
// If provided, it will be used to generate semantic worktree/branch names.
// The title is sanitized and truncated to 20 characters.
TaskTitle string
// RepositoryID is the repository identifier (required).
RepositoryID string
// RepositoryPath is the local path to the main repository (required).
RepositoryPath string
// BaseBranch is the branch to base the worktree on (required).
// Typically "main" or "master".
BaseBranch string
// FallbackBaseBranch is an optional branch to retry with when BaseBranch
// does not exist in the repository. Typically populated with the
// repository's default_branch by the caller. When empty, a missing
// BaseBranch returns ErrInvalidBaseBranch as before.
FallbackBaseBranch string
// CheckoutBranch is a branch to fetch from origin and check out directly in the
// worktree. If the branch is already checked out in another worktree, a unique
// fallback branch is created using the original name with a random suffix.
CheckoutBranch string
// PRNumber, when > 0, identifies the GitHub PR whose head ref should be
// fetched into the local CheckoutBranch via the refs/pull/<N>/head refspec.
// Required for fork PRs, whose branches only exist on the contributor's
// fork — git fetch origin <branch> fails because origin (the base repo)
// has no such ref. GitHub mirrors every PR head under refs/pull/<N>/head
// on the base repo, so the refspec form works for same-repo and fork PRs
// uniformly without needing to add the fork as a remote.
PRNumber int
// WorktreeBranchPrefix is the prefix to use for the worktree branch name.
// If empty, the default prefix is used.
WorktreeBranchPrefix string
// PullBeforeWorktree indicates whether to pull from remote before creating the worktree.
PullBeforeWorktree bool
// WorktreeID is the ID of an existing worktree to reuse (optional).
// If provided and valid, the existing worktree is returned instead of creating a new one.
WorktreeID string
// TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12").
// When set together with RepoName, the worktree is placed at
// ~/.kandev/tasks/{TaskDirName}/{RepoName}/ instead of ~/.kandev/worktrees/.
TaskDirName string
// RepoName is the repository name used as subdirectory inside the task directory.
// Only used when TaskDirName is also set.
RepoName string
// BranchSlug, when non-empty, suffixes the per-repo sibling directory so
// the same repo can host multiple branches inside one task. Path becomes
// ~/.kandev/tasks/{TaskDirName}/{RepoName}-{BranchSlug}/ — a sibling of
// the primary {RepoName}/ entry, NOT nested under it (nesting would
// break agentctl's sibling-based multi-repo detection). Callers must
// derive a deterministic, filesystem-safe slug (see SanitizeBranchSlug).
BranchSlug string
// OnSyncProgress receives progress updates for pre-worktree branch sync.
OnSyncProgress SyncProgressCallback
// OnWorktreeCreated, when set, is invoked once the worktree directory has
// been created and persisted (git worktree add succeeded) but BEFORE the
// per-repo setup script runs. The env preparer uses it to complete the
// "Create worktree" UI step so the setup script renders as a distinct,
// subsequent step instead of overlapping it. The passed worktree already
// carries any base-branch fallback warning. Called synchronously on the
// Create goroutine; not invoked when an existing worktree is reused.
OnWorktreeCreated func(*Worktree)
}
// Validate validates the create request.
func (r *CreateRequest) Validate() error {
if r.TaskID == "" {
return ErrWorktreeNotFound
}
if r.RepositoryPath == "" {
return ErrRepoNotGit
}
if r.BaseBranch == "" {
// Defence-in-depth: prefer the explicit FallbackBaseBranch (typically
// the repository's default_branch carried by the caller) over an
// outright rejection. The manager's branchExists check still verifies
// the resulting ref before any git work runs.
if r.FallbackBaseBranch == "" {
return ErrInvalidBaseBranch
}
r.BaseBranch = r.FallbackBaseBranch
}
return nil
}
// MergeRequest contains the parameters for merging a worktree's branch.
type MergeRequest struct {
// TaskID identifies the worktree to merge.
TaskID string
// Method is the merge method: "merge", "squash", or "rebase".
Method string
// CleanupAfter indicates whether to delete the worktree after merging.
CleanupAfter bool
}
// StatusActive is the status for an active, usable worktree.
const StatusActive = "active"
// StatusMerged is the status for a worktree whose branch has been merged.
const StatusMerged = "merged"
// StatusDeleted is the status for a deleted worktree.
const StatusDeleted = "deleted"