fix(orchestrator): resolve subtask base_branch correctly across repos (#870)

This commit is contained in:
Carlos Florêncio
2026-05-11 20:34:04 +01:00
committed by GitHub
parent 71ee104178
commit 00fed4661e
16 changed files with 540 additions and 31 deletions
+2
View File
@@ -93,6 +93,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
RepositoryID: req.RepositoryID,
RepositoryPath: req.RepositoryPath,
BaseBranch: req.BaseBranch,
DefaultBranch: req.DefaultBranch,
CheckoutBranch: req.CheckoutBranch,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
PullBeforeWorktree: req.PullBeforeWorktree,
@@ -111,6 +112,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
RepositoryURL: r.RepositoryURL,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
DefaultBranch: r.DefaultBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
@@ -29,6 +29,7 @@ type RepoPrepareSpec struct {
RepositoryPath string
RepoName string
BaseBranch string
DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing
CheckoutBranch string
WorktreeID string
WorktreeBranch string
@@ -51,6 +52,7 @@ type EnvPrepareRequest struct {
SetupScript string
RepoSetupScript string // Repository-level setup script (e.g. "make install")
BaseBranch string
DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing
CheckoutBranch string
WorktreeID string
WorktreeBranch string
@@ -86,6 +88,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec {
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
DefaultBranch: r.DefaultBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranch: r.WorktreeBranch,
@@ -166,6 +166,7 @@ func (p *WorktreePreparer) createWorktreeWithSync(
RepositoryID: req.RepositoryID,
RepositoryPath: req.RepositoryPath,
BaseBranch: req.BaseBranch,
FallbackBaseBranch: req.DefaultBranch,
CheckoutBranch: req.CheckoutBranch,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
PullBeforeWorktree: req.PullBeforeWorktree,
@@ -191,6 +192,14 @@ func (p *WorktreePreparer) createWorktreeWithSync(
}
completeStepSuccess(&step)
if wt.BaseBranchFallbackWarning != "" {
step.Warning = wt.BaseBranchFallbackWarning
step.WarningDetail = wt.BaseBranchFallbackDetail
}
// wt.FetchWarning is surfaced in the separate "Fetch PR branch" step
// rendered later in the pipeline. It can only be non-empty when
// req.CheckoutBranch != "" (it is set inside fetchBranchToLocal), so the
// two warnings always co-occur and FetchWarning is never silently dropped.
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
return wt, steps, stepIdx + 1, nil
@@ -356,6 +365,7 @@ func (p *WorktreePreparer) prepareOneRepo(
subReq.RepositoryPath = spec.RepositoryPath
subReq.RepoName = spec.RepoName
subReq.BaseBranch = spec.BaseBranch
subReq.DefaultBranch = spec.DefaultBranch
subReq.CheckoutBranch = spec.CheckoutBranch
subReq.WorktreeID = spec.WorktreeID
subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix
@@ -384,6 +384,7 @@ func (m *Manager) runEnvironmentPreparer(
SetupScript: req.SetupScript,
RepoSetupScript: repoSetupScript,
BaseBranch: req.BaseBranch,
DefaultBranch: req.DefaultBranch,
CheckoutBranch: req.CheckoutBranch,
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
PullBeforeWorktree: req.PullBeforeWorktree,
@@ -406,6 +407,7 @@ func (m *Manager) runEnvironmentPreparer(
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
DefaultBranch: r.DefaultBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
@@ -272,6 +272,7 @@ type RepoLaunchSpec struct {
RepositoryURL string // Clone URL for remote executors that need to clone
RepoName string // Repository name used as subdirectory inside TaskDirName
BaseBranch string
DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing
CheckoutBranch string
WorktreeID string // Existing worktree ID to reuse (skip creation if set)
WorktreeBranchPrefix string
@@ -314,6 +315,7 @@ type LaunchRequest struct {
RepositoryID string // Repository ID for worktree tracking
RepositoryPath string // Path to the main repository (for worktree creation)
BaseBranch string // Base branch for the worktree (e.g., "main")
DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing
CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch)
WorktreeBranchPrefix string // Branch prefix for worktree branches
PullBeforeWorktree bool // Whether to pull from remote before creating the worktree
@@ -345,6 +347,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec {
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
DefaultBranch: r.DefaultBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
+29 -10
View File
@@ -450,7 +450,7 @@ func (h *Handlers) resolveTaskRepositories(
parentID, sourceTaskID string,
explicit []mcpRepositoryInput,
) (taskRepoResult, error) {
explicitRepos := explicitRepoInputs(explicit)
explicitRepos := h.explicitRepoInputsWithDefaults(ctx, explicit)
if parentID != "" {
parent, err := h.taskSvc.GetTask(ctx, parentID)
@@ -504,27 +504,47 @@ func (h *Handlers) resolveTaskRepositories(
return taskRepoResult{}, nil
}
// explicitRepoInputs maps the MCP-side explicit repo list to service inputs.
// Returns nil when no explicit repos were supplied so callers can distinguish
// "agent didn't pass repos" from "agent passed an empty list".
func explicitRepoInputs(explicit []mcpRepositoryInput) []service.TaskRepositoryInput {
// explicitRepoInputsWithDefaults maps the MCP-side explicit repo list to
// service inputs. Returns nil when no explicit repos were supplied so callers
// can distinguish "agent didn't pass repos" from "agent passed an empty list".
//
// When an explicit entry pins a repository_id without a base_branch, the
// repository's default_branch is filled in. This anchors cross-repo subtasks
// (a parent on feature/foo creating a child in another repo) to a known-good
// branch instead of an empty value that would force every downstream consumer
// to recompute the default.
func (h *Handlers) explicitRepoInputsWithDefaults(ctx context.Context, explicit []mcpRepositoryInput) []service.TaskRepositoryInput {
if len(explicit) == 0 {
return nil
}
repos := make([]service.TaskRepositoryInput, 0, len(explicit))
for _, r := range explicit {
baseBranch := r.BaseBranch
if baseBranch == "" && r.RepositoryID != "" && h.taskSvc != nil {
if repo, err := h.taskSvc.GetRepository(ctx, r.RepositoryID); err == nil && repo != nil {
baseBranch = repo.DefaultBranch
}
}
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
LocalPath: r.LocalPath,
GitHubURL: r.GitHubURL,
BaseBranch: r.BaseBranch,
BaseBranch: baseBranch,
})
}
return repos
}
// inheritedRepoInputs maps an existing task's repository list onto service
// inputs for a new task that inherits from it.
// inputs for a new task that inherits from it. RepositoryID and BaseBranch
// carry over so a same-repo subtask branches off the same point as the
// parent (sibling branches off the same base, ergonomically aligned for
// stacked PRs). CheckoutBranch is dropped on purpose: two worktrees cannot
// share a working branch, so the subtask's session generates a fresh one.
// Agents that need a different base for a same-repo subtask must pass
// base_branch explicitly. If the inherited base_branch is missing on the
// remote at launch time, the worktree manager's fallback recovers to the
// repository's default_branch and surfaces a warning.
func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryInput {
if len(src) == 0 {
return nil
@@ -535,9 +555,8 @@ func inheritedRepoInputs(src []*models.TaskRepository) []service.TaskRepositoryI
continue
}
repos = append(repos, service.TaskRepositoryInput{
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
RepositoryID: r.RepositoryID,
BaseBranch: r.BaseBranch,
})
}
return repos
@@ -266,26 +266,37 @@ func TestResolveTaskRepositories_NoInputs_ReturnsEmpty(t *testing.T) {
// --- Integration tests using real task service ---
// seedParentWithRepo creates a workspace, workflow, repository, and a parent
// task linked to that repository. Returns the parent task ID.
// task linked to that repository. Returns the parent task ID. The parent's
// task_repository row is anchored to a non-default branch ("pr-metrics") on
// purpose so inheritance tests can assert what subtasks do with the parent's
// branch (same-repo subtasks inherit it for stacked-PR ergonomics; the
// worktree manager's fallback rescues launches if the branch went stale).
func seedParentWithRepo(t *testing.T, svc *service.Service, repo *sqliterepo.Repository) string {
t.Helper()
ctx := context.Background()
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"}))
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"}))
require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ID: "repo-parent", WorkspaceID: "ws-1", Name: "Parent Repo"}))
require.NoError(t, repo.CreateRepository(ctx, &models.Repository{
ID: "repo-parent", WorkspaceID: "ws-1", Name: "Parent Repo", DefaultBranch: "main",
}))
parent, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
Title: "Parent task",
Repositories: []service.TaskRepositoryInput{
{RepositoryID: "repo-parent", BaseBranch: "main"},
{RepositoryID: "repo-parent", BaseBranch: "pr-metrics"},
},
})
require.NoError(t, err)
return parent.ID
}
func TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsAll(t *testing.T) {
// TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsRepoAndBaseBranch
// asserts the same-repo subtask path: the parent's RepositoryID and
// BaseBranch carry over so the subtask branches off the same starting point
// (sibling branches, ergonomic for stacked PRs). CheckoutBranch is dropped
// because two worktrees cannot share a working branch.
func TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsRepoAndBaseBranch(t *testing.T) {
svc, repo := newTestTaskService(t)
parentID := seedParentWithRepo(t, svc, repo)
@@ -296,10 +307,79 @@ func TestResolveTaskRepositories_ParentWithoutExplicitRepos_InheritsAll(t *testi
require.NoError(t, err)
require.Len(t, result.Repos, 1, "subtask without explicit repos inherits parent's repos")
assert.Equal(t, "repo-parent", result.Repos[0].RepositoryID)
assert.Equal(t, "pr-metrics", result.Repos[0].BaseBranch, "same-repo subtask should inherit parent's base_branch for stacked-PR ergonomics")
assert.Empty(t, result.Repos[0].CheckoutBranch, "subtask must not inherit parent's checkout_branch (worktrees cannot share a branch)")
assert.Equal(t, "ws-1", result.WorkspaceID)
assert.Equal(t, "wf-1", result.WorkflowID)
}
// TestCreateSubtaskFromParent_SameRepoInheritsParentBaseBranch is the
// end-to-end check that the parent's base_branch is persisted onto the
// subtask's task_repository row when the subtask targets the same repo.
// This is the desired behaviour: agents stacking work on top of a parent
// PR get a subtask anchored to the same starting point. The worktree
// manager's fallback (covered in worktree tests) is the safety net for
// cases where the inherited branch later goes stale.
func TestCreateSubtaskFromParent_SameRepoInheritsParentBaseBranch(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
parentID := seedParentWithRepo(t, svc, repo)
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
resolved, err := h.resolveTaskRepositories(ctx, parentID, "", nil)
require.NoError(t, err)
subtask, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: resolved.WorkspaceID,
WorkflowID: resolved.WorkflowID,
ParentID: parentID,
Title: "Child",
Description: "do the thing",
Repositories: resolved.Repos,
})
require.NoError(t, err)
require.Len(t, subtask.Repositories, 1)
assert.Equal(t, "repo-parent", subtask.Repositories[0].RepositoryID)
assert.Equal(t, "pr-metrics", subtask.Repositories[0].BaseBranch, "same-repo subtask should inherit parent's base_branch")
}
// TestCreateSubtaskFromParent_DifferentRepoUsesNewRepoDefault verifies the
// cross-repo path: when the agent points the subtask at a different repo
// (via repository_id / repository_url / local_path) without an explicit
// base_branch, the subtask anchors to that repo's default_branch — never
// the parent's branch.
func TestCreateSubtaskFromParent_DifferentRepoUsesNewRepoDefault(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
parentID := seedParentWithRepo(t, svc, repo)
require.NoError(t, repo.CreateRepository(ctx, &models.Repository{
ID: "repo-sibling", WorkspaceID: "ws-1", Name: "Sibling", DefaultBranch: "trunk",
}))
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
explicit := []mcpRepositoryInput{{RepositoryID: "repo-sibling"}}
resolved, err := h.resolveTaskRepositories(ctx, parentID, "", explicit)
require.NoError(t, err)
subtask, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: resolved.WorkspaceID,
WorkflowID: resolved.WorkflowID,
ParentID: parentID,
Title: "Cross-repo child",
Description: "do the thing",
Repositories: resolved.Repos,
})
require.NoError(t, err)
require.Len(t, subtask.Repositories, 1)
assert.Equal(t, "repo-sibling", subtask.Repositories[0].RepositoryID)
assert.Equal(t, "trunk", subtask.Repositories[0].BaseBranch, "cross-repo subtask should anchor to the new repo's default_branch, not parent's pr-metrics")
}
func TestResolveTaskRepositories_ParentWithExplicitRepos_OverridesRepoButInheritsWorkspace(t *testing.T) {
svc, repo := newTestTaskService(t)
parentID := seedParentWithRepo(t, svc, repo)
@@ -28,7 +28,7 @@ func (s *Server) registerConfigWorkflowTools() {
)
s.mcpServer.AddTool(
mcp.NewTool("list_repositories_kandev",
mcp.WithDescription("List repositories in a workspace. Use this to find a repository_id to attach to create_task_kandev when the task should target a specific codebase."),
mcp.WithDescription("List repositories in a workspace. Use this to find a repository_id to attach to create_task_kandev when the task should target a specific codebase. Each repository entry includes default_branch — use it to pick or confirm a base_branch when creating a subtask in a different repo."),
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
),
s.wrapHandler("list_repositories_kandev", s.listRepositoriesHandler()),
+5 -1
View File
@@ -444,6 +444,10 @@ WHEN TO OMIT parent_id (top-level task):
IMPORTANT:
- Subtasks inherit workspace, workflow, agent profile, and executor from the parent
- Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead (must live in the parent's workspace)
- base_branch behaviour:
- Same repo as parent (no repo args): subtask inherits the parent's base_branch (sibling branches off the same starting point — useful for PR stacks)
- Different repo (you passed repository_url / repository_id / local_path): subtask defaults to that repo's default_branch
- Pass base_branch explicitly to override either default. Use list_repositories_kandev to see each repo's default_branch.
- Top-level tasks need a repository via repository_url, repository_id, or local_path
- 'description' is the sub-agent's initial prompt — be specific and detailed
- Set start_agent=false to create without starting an agent`
@@ -476,7 +480,7 @@ IMPORTANT:
mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional, defaults to repository's default branch.")),
mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional. Defaults: same-repo subtasks inherit the parent's base_branch; cross-repo subtasks and top-level tasks fall back to the repository's default_branch (visible via list_repositories_kandev).")),
),
s.wrapHandler("create_task_kandev", s.createTaskHandler()),
)
@@ -257,6 +257,7 @@ type LaunchAgentRequest struct {
RepositoryID string // Repository ID for worktree tracking
RepositoryPath string // Path to the main repository (for worktree creation)
BaseBranch string // Base branch for the worktree (e.g., "main")
DefaultBranch string // Repository's default_branch, used as a fallback when BaseBranch is missing
CheckoutBranch string // Branch to fetch and checkout after worktree creation (e.g., PR head branch)
WorktreeBranchPrefix string // Branch prefix for worktree branches
PullBeforeWorktree bool // Whether to pull from remote before creating the worktree
@@ -282,6 +283,7 @@ type RepoSpec struct {
RepositoryURL string
RepoName string
BaseBranch string
DefaultBranch string // Repository's default_branch, used as fallback when BaseBranch is missing
CheckoutBranch string
WorktreeID string
WorktreeBranchPrefix string
@@ -639,6 +639,7 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec {
spec.RepoName = info.Repository.Name
spec.RepoSetupScript = info.Repository.SetupScript
spec.RepoCleanupScript = info.Repository.CleanupScript
spec.DefaultBranch = info.Repository.DefaultBranch
}
// Containerized executors need a clone URL; reuse the same helper as
// the single-repo path (best-effort — skipped if Repository is nil).
@@ -661,6 +662,9 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task,
req.CheckoutBranch = repoInfo.CheckoutBranch
req.WorktreeBranchPrefix = repoInfo.WorktreeBranchPrefix
req.PullBeforeWorktree = repoInfo.PullBeforeWorktree
if repoInfo.Repository != nil {
req.DefaultBranch = repoInfo.Repository.DefaultBranch
}
// Task directory mode: place worktree inside per-task directory
if req.UseWorktree && repoInfo.Repository != nil && repoInfo.Repository.Name != "" {
req.TaskDirName = worktree.SemanticWorktreeName(task.Title, worktree.SmallSuffix(3))
+46 -3
View File
@@ -274,9 +274,44 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err
baseRef = m.pullBaseBranch(ctx, req.RepositoryPath, req.BaseBranch, req.OnSyncProgress)
}
// Check base branch exists
// Check base branch exists. When the requested branch is missing and the
// caller supplied a FallbackBaseBranch (typically the repo's default_branch)
// that does exist, recover automatically and surface a non-fatal warning on
// the resulting worktree. This handles the common case where a parent task
// was anchored to a PR head or fresh branch that has been deleted upstream
// or never existed in a sibling repo.
fallbackWarning, fallbackDetail := "", ""
if !m.branchExists(ctx, req.RepositoryPath, baseRef) {
return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef)
fallback := strings.TrimSpace(req.FallbackBaseBranch)
if fallback == "" || fallback == baseRef {
return nil, fmt.Errorf("%w: %s", ErrInvalidBaseBranch, baseRef)
}
// Best-effort fetch of the fallback so it is available locally in
// containerized / shallow-clone environments where the fallback may
// only exist on the remote. pullBaseBranch may resolve the name to a
// remote-tracking ref (e.g. "main" -> "origin/main") which we must use
// for the existence check and downstream git operations.
resolvedFallback := fallback
if req.PullBeforeWorktree {
resolvedFallback = m.pullBaseBranch(ctx, req.RepositoryPath, fallback, nil)
}
if !m.branchExists(ctx, req.RepositoryPath, resolvedFallback) {
return nil, fmt.Errorf("%w: %s (fallback %q also not found)", ErrInvalidBaseBranch, baseRef, fallback)
}
m.logger.Warn("requested base branch not found, falling back",
zap.String("repository_path", req.RepositoryPath),
zap.String("requested_branch", baseRef),
zap.String("fallback_branch", fallback))
// Use req.BaseBranch (the user-supplied name) in the user-facing warning
// rather than baseRef, which may carry an internal "origin/<x>" form
// produced by pullBaseBranch when PullBeforeWorktree is set.
fallbackWarning = fmt.Sprintf("Requested base branch %q not found, used %q instead", req.BaseBranch, fallback)
fallbackDetail = fmt.Sprintf("git rev-parse --verify %s failed; recovered using fallback branch %q (typically the repository's default_branch)", baseRef, fallback)
baseRef = resolvedFallback
// Reflect the resolved branch in the persisted worktree record so
// downstream consumers (UI, queries, debug logs) see the actual base
// rather than the requested-but-missing one.
req.BaseBranch = fallback
}
// Worktrees are always placed under ~/.kandev/tasks/{taskDir}/{repo}/.
@@ -285,7 +320,15 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err
if req.TaskDirName == "" || req.RepoName == "" {
return nil, ErrTaskDirRequired
}
return m.createInTaskDir(ctx, req, baseRef)
wt, err := m.createInTaskDir(ctx, req, baseRef)
if err != nil {
return nil, err
}
if fallbackWarning != "" {
wt.BaseBranchFallbackWarning = fallbackWarning
wt.BaseBranchFallbackDetail = fallbackDetail
}
return wt, nil
}
// createInTaskDir creates a worktree inside the task directory structure:
@@ -0,0 +1,142 @@
package worktree
import (
"context"
"errors"
"strings"
"testing"
)
// TestCreateWorktree_BaseBranchFallback_RecoversWhenRequestedMissing covers
// the regression where a subtask inherited a stale base_branch (e.g. the
// parent's PR head) that did not exist in a freshly inherited worktree. With
// FallbackBaseBranch populated (typically the repo's default_branch) the
// manager retries with the fallback and surfaces a non-fatal warning instead
// of failing the launch.
func TestCreateWorktree_BaseBranchFallback_RecoversWhenRequestedMissing(t *testing.T) {
cfg := newTestConfig(t)
mgr, err := NewManager(cfg, newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
repoPath := initGitRepoForWorktreeTest(t)
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-1",
SessionID: "session-1",
TaskTitle: "Inherits stale branch",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "pr-metrics",
FallbackBaseBranch: "main",
TaskDirName: "task-1",
RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() unexpected error: %v", err)
}
if wt.BaseBranch != "main" {
t.Fatalf("worktree BaseBranch = %q, want %q", wt.BaseBranch, "main")
}
if wt.BaseBranchFallbackWarning == "" {
t.Fatal("expected BaseBranchFallbackWarning to be set when fallback was used")
}
if !strings.Contains(wt.BaseBranchFallbackWarning, "pr-metrics") {
t.Fatalf("warning %q does not mention requested branch", wt.BaseBranchFallbackWarning)
}
if !strings.Contains(wt.BaseBranchFallbackWarning, "main") {
t.Fatalf("warning %q does not mention fallback branch", wt.BaseBranchFallbackWarning)
}
if wt.BaseBranchFallbackDetail == "" {
t.Fatal("expected BaseBranchFallbackDetail to be set when fallback was used")
}
}
// TestCreateWorktree_BaseBranchFallback_NoFallbackProvided keeps the legacy
// loud failure when the caller did not supply a FallbackBaseBranch — we must
// not silently invent a branch out of thin air.
func TestCreateWorktree_BaseBranchFallback_NoFallbackProvided(t *testing.T) {
cfg := newTestConfig(t)
mgr, err := NewManager(cfg, newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
repoPath := initGitRepoForWorktreeTest(t)
_, err = mgr.Create(context.Background(), CreateRequest{
TaskID: "task-1",
SessionID: "session-1",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "pr-metrics",
TaskDirName: "task-1",
RepoName: "repo-1",
})
if !errors.Is(err, ErrInvalidBaseBranch) {
t.Fatalf("Create() err = %v, want ErrInvalidBaseBranch", err)
}
}
// TestCreateWorktree_BaseBranchFallback_FallbackAlsoMissing surfaces the
// original error when neither branch exists — the fallback only helps when it
// actually exists in the repository.
func TestCreateWorktree_BaseBranchFallback_FallbackAlsoMissing(t *testing.T) {
cfg := newTestConfig(t)
mgr, err := NewManager(cfg, newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
repoPath := initGitRepoForWorktreeTest(t)
_, err = mgr.Create(context.Background(), CreateRequest{
TaskID: "task-1",
SessionID: "session-1",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "pr-metrics",
FallbackBaseBranch: "release", // also missing in this repo
TaskDirName: "task-1",
RepoName: "repo-1",
})
if !errors.Is(err, ErrInvalidBaseBranch) {
t.Fatalf("Create() err = %v, want ErrInvalidBaseBranch", err)
}
// The error must name both branches so an operator can tell at a glance
// that a fallback was attempted and also failed.
msg := err.Error()
if !strings.Contains(msg, "pr-metrics") || !strings.Contains(msg, "release") {
t.Fatalf("error %q should mention both requested and fallback branches", msg)
}
}
// TestCreateWorktree_BaseBranchFallback_NotUsedWhenBaseExists guards against
// the fallback being applied unnecessarily — when the requested branch is
// valid the worktree uses it and no warning is emitted.
func TestCreateWorktree_BaseBranchFallback_NotUsedWhenBaseExists(t *testing.T) {
cfg := newTestConfig(t)
mgr, err := NewManager(cfg, newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
repoPath := initGitRepoForWorktreeTest(t)
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-1",
SessionID: "session-1",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "main",
FallbackBaseBranch: "feature/pr-branch", // exists, but should never be used
TaskDirName: "task-1",
RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() unexpected error: %v", err)
}
if wt.BaseBranch != "main" {
t.Fatalf("worktree BaseBranch = %q, want %q", wt.BaseBranch, "main")
}
if wt.BaseBranchFallbackWarning != "" {
t.Fatalf("expected no fallback warning, got %q", wt.BaseBranchFallbackWarning)
}
}
@@ -72,6 +72,17 @@ type Worktree struct {
// 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"`
}
// CreateRequest contains the parameters for creating a new worktree.
@@ -97,6 +108,12 @@ type CreateRequest struct {
// 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.
+28 -11
View File
@@ -108,25 +108,36 @@ function buildCreateTaskBody(
return body;
}
/** Build the optional fields object for createTaskWithAgent requests. */
function buildOptionalAgentTaskFields(opts?: {
type OptionalAgentTaskOpts = {
workflow_id?: string;
workflow_step_id?: string;
repository_ids?: string[];
repositories?: Array<{ repository_id: string; base_branch?: string; checkout_branch?: string }>;
executor_id?: string;
executor_profile_id?: string;
metadata?: Record<string, unknown>;
parent_id?: string;
}): Record<string, unknown> {
};
/** `repositories` (with per-entry branches) takes precedence over the shorthand
* `repository_ids` so callers can pin a non-default base_branch. */
function pickRepositories(opts: OptionalAgentTaskOpts): unknown {
if (opts.repositories) return opts.repositories;
if (opts.repository_ids) return opts.repository_ids.map((id) => ({ repository_id: id }));
return undefined;
}
/** Build the optional fields object for createTaskWithAgent requests. */
function buildOptionalAgentTaskFields(opts?: OptionalAgentTaskOpts): Record<string, unknown> {
const fields: Record<string, unknown> = {};
if (opts?.workflow_id) fields.workflow_id = opts.workflow_id;
if (opts?.workflow_step_id) fields.workflow_step_id = opts.workflow_step_id;
if (opts?.repository_ids)
fields.repositories = opts.repository_ids.map((id) => ({ repository_id: id }));
if (opts?.executor_id) fields.executor_id = opts.executor_id;
if (opts?.executor_profile_id) fields.executor_profile_id = opts.executor_profile_id;
if (opts?.metadata) fields.metadata = opts.metadata;
if (opts?.parent_id) fields.parent_id = opts.parent_id;
if (!opts) return fields;
setIf(fields, "workflow_id", opts.workflow_id);
setIf(fields, "workflow_step_id", opts.workflow_step_id);
setIf(fields, "repositories", pickRepositories(opts));
setIf(fields, "executor_id", opts.executor_id);
setIf(fields, "executor_profile_id", opts.executor_profile_id);
setIf(fields, "metadata", opts.metadata);
setIf(fields, "parent_id", opts.parent_id);
return fields;
}
@@ -320,6 +331,12 @@ export class ApiClient {
workflow_id?: string;
workflow_step_id?: string;
repository_ids?: string[];
/** Full repository entries with optional checkout_branch / base_branch. */
repositories?: Array<{
repository_id: string;
base_branch?: string;
checkout_branch?: string;
}>;
executor_id?: string;
executor_profile_id?: string;
metadata?: Record<string, unknown>;
+162 -1
View File
@@ -12,7 +12,7 @@ const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
type TaskWithRepos = {
id: string;
repositories?: Array<{ repository_id: string }>;
repositories?: Array<{ repository_id: string; base_branch?: string }>;
};
type SessionInfo = {
@@ -440,6 +440,167 @@ test.describe("MCP subtask creation", () => {
).toContain(otherRepo.id);
expect(repoIds).not.toContain(seedData.repositoryId);
});
test("MCP-created same-repo subtask inherits parent's base_branch (stacked PR ergonomics)", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// 1. Set up a repo with a non-default branch the parent will be pinned to.
// A same-repo subtask should inherit that branch so its work stacks on
// the same starting point as the parent (sibling PRs off the same
// base, useful for stacked PR workflows).
const repoDir = path.join(backend.tmpDir, "repos", "subtask-same-repo-inherit");
fs.mkdirSync(repoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
execSync("git branch feature/parent-branch", { cwd: repoDir, env: gitEnv });
const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
name: "Subtask Same Repo Inherit",
});
const subtaskTitle = `Same-Repo Subtask ${Date.now()}`;
const script = [
'e2e:thinking("Creating subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E subtask: verify base_branch is inherited from parent in same repo"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
// 2. Create the parent pinned to a non-default branch.
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Same-Repo Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repositories: [{ repository_id: repo.id, base_branch: "feature/parent-branch" }],
},
);
// Sanity: parent persisted the pin.
const parentRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${parentTask.id}`);
const parentData = (await parentRaw.json()) as TaskWithRepos;
expect(parentData.repositories?.[0]?.base_branch).toBe("feature/parent-branch");
// 3. Open the parent so the execution stays alive while the script runs.
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// 4. Poll for the subtask.
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{ timeout: 60_000, message: "Subtask should be created by the agent's MCP call" },
)
.toBeDefined();
// 5. The same-repo subtask should inherit the parent's base_branch so the
// agent can stack work on top of the parent's PR. The worktree
// manager's fallback (covered separately in worktree unit tests) is
// the safety net if the inherited branch later goes stale on the
// remote — it is not the primary mechanism.
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
expect(
subtaskData.repositories?.[0]?.repository_id,
"same-repo subtask should inherit the parent's repository_id",
).toBe(repo.id);
expect(
subtaskData.repositories?.[0]?.base_branch,
`same-repo subtask base_branch should inherit parent's 'feature/parent-branch'; got: ${subtaskData.repositories?.[0]?.base_branch}`,
).toBe("feature/parent-branch");
});
test("MCP-created cross-repo subtask uses the new repo's default_branch", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
// Parent lives in the seed repo on a non-default branch. The subtask
// explicitly retargets a different repo via repository_id and supplies
// no base_branch. The subtask must anchor to the new repo's
// default_branch ("trunk") and never carry the parent's branch over.
const otherRepoDir = path.join(backend.tmpDir, "repos", "subtask-cross-repo");
fs.mkdirSync(otherRepoDir, { recursive: true });
const gitEnv = makeGitEnv(backend.tmpDir);
execSync("git init -b trunk", { cwd: otherRepoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: otherRepoDir, env: gitEnv });
const otherRepo = await apiClient.createRepository(
seedData.workspaceId,
otherRepoDir,
"trunk",
{
name: "Subtask Cross Repo",
},
);
const subtaskTitle = `Cross-Repo Subtask ${Date.now()}`;
const script = [
'e2e:thinking("Creating cross-repo subtask...")',
"e2e:delay(100)",
`e2e:mcp:kandev:create_task_kandev({"parent_id":"self","title":"${subtaskTitle}","description":"E2E cross-repo subtask","repository_id":"${otherRepo.id}"})`,
"e2e:delay(100)",
'e2e:message("Done.")',
].join("\n");
const parentTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Cross-Repo Parent Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${parentTask.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
type TaskEntry = { id: string; title: string };
let subtask: TaskEntry | undefined;
await expect
.poll(
async () => {
const allTasks = await apiClient.listTasks(seedData.workspaceId);
subtask = allTasks.tasks.find((t: TaskEntry) => t.title === subtaskTitle);
return subtask;
},
{
timeout: 60_000,
message: "Cross-repo subtask should be created by the agent's MCP call",
},
)
.toBeDefined();
const subtaskRaw = await apiClient.rawRequest("GET", `/api/v1/tasks/${subtask!.id}`);
const subtaskData = (await subtaskRaw.json()) as TaskWithRepos;
expect(subtaskData.repositories?.[0]?.repository_id).toBe(otherRepo.id);
expect(
subtaskData.repositories?.[0]?.base_branch,
`cross-repo subtask base_branch should anchor to new repo's default 'trunk'; got: ${subtaskData.repositories?.[0]?.base_branch}`,
).toBe("trunk");
});
});
/**