fix: reuse worktrees for same-task handoffs (#1510)
This commit is contained in:
@@ -23,6 +23,49 @@ func TestCollectBaseBranches_MultiRepo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBaseBranches_MultiBranchKeysUseWorktreeSubpath(t *testing.T) {
|
||||
req := &LaunchRequest{
|
||||
Repositories: []RepoLaunchSpec{
|
||||
{RepoName: "kandev", BaseBranch: "main"},
|
||||
{RepoName: "kandev", BranchSlug: "feature-x", BaseBranch: "feature/x"},
|
||||
},
|
||||
}
|
||||
got := collectBaseBranches(req)
|
||||
want := map[string]string{"kandev": "main", "kandev-feature-x": "feature/x"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collectBaseBranches = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBranchMetadataKey_FallsBackToRawRepoNameWhenSanitizedEmpty(t *testing.T) {
|
||||
spec := RepoLaunchSpec{RepoName: "/", BaseBranch: "main"}
|
||||
if got := baseBranchMetadataKey(spec); got != "/" {
|
||||
t.Fatalf("baseBranchMetadataKey = %q, want raw repo name", got)
|
||||
}
|
||||
req := &LaunchRequest{
|
||||
Repositories: []RepoLaunchSpec{spec},
|
||||
}
|
||||
got := collectBaseBranches(req)
|
||||
want := map[string]string{"/": "main"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collectBaseBranches = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectBaseBranches_SkipsMalformedEmptyRepoSpec(t *testing.T) {
|
||||
req := &LaunchRequest{
|
||||
BaseBranch: "legacy-main",
|
||||
Repositories: []RepoLaunchSpec{
|
||||
{BaseBranch: "malformed-main"},
|
||||
},
|
||||
}
|
||||
got := collectBaseBranches(req)
|
||||
want := map[string]string{"": "legacy-main"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collectBaseBranches = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectBaseBranches_SingleRepoLegacyKey verifies the synthesized
|
||||
// single-repo path: when only top-level BaseBranch is set, it lands under the
|
||||
// empty key so the root WorkspaceTracker (repositoryName == "") finds it.
|
||||
|
||||
@@ -38,9 +38,14 @@ type RepoPrepareSpec struct {
|
||||
WorktreeBranchPrefix string
|
||||
PullBeforeWorktree bool
|
||||
RepoSetupScript string
|
||||
// BranchSlug, when set, nests the worktree path under {RepoName}/{BranchSlug}/
|
||||
// so two specs sharing a RepositoryID don't collide on disk.
|
||||
// BranchSlug, when set, suffixes the worktree path as
|
||||
// {RepoName}-{BranchSlug} so two specs sharing a RepositoryID don't collide
|
||||
// on disk.
|
||||
BranchSlug string
|
||||
// BranchIdentitySlug is the stable branch key used for worktree reuse and
|
||||
// persisted environment metadata. It may be non-empty even when BranchSlug
|
||||
// is empty so primary branches can keep the flat legacy path.
|
||||
BranchIdentitySlug string
|
||||
}
|
||||
|
||||
// EnvPrepareRequest contains the parameters for environment preparation.
|
||||
@@ -68,7 +73,11 @@ type EnvPrepareRequest struct {
|
||||
|
||||
TaskDirName string // Per-task directory name within the workspace (e.g. "task-abc123")
|
||||
RepoName string // Repository slug used with TaskDirName to locate checkouts
|
||||
BranchSlug string // Optional branch subdir for multi-branch tasks (legacy single-repo path)
|
||||
BranchSlug string // Optional branch directory suffix for multi-branch tasks
|
||||
// BranchIdentitySlug is the stable branch key for worktree cache/persistence.
|
||||
// It may be non-empty when BranchSlug is empty to preserve a flat path.
|
||||
// Empty leaves the synthesized spec identity empty; worktree code falls back.
|
||||
BranchIdentitySlug string
|
||||
|
||||
// Repositories carries one entry per repository when the request is
|
||||
// multi-repo. When non-empty it is the source of truth; the legacy
|
||||
@@ -104,6 +113,7 @@ func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec {
|
||||
PullBeforeWorktree: r.PullBeforeWorktree,
|
||||
RepoSetupScript: r.RepoSetupScript,
|
||||
BranchSlug: r.BranchSlug,
|
||||
BranchIdentitySlug: r.BranchIdentitySlug,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -125,6 +135,7 @@ type PrepareStep struct {
|
||||
// to one RepoPrepareSpec from the request.
|
||||
type RepoWorktreeResult struct {
|
||||
RepositoryID string `json:"repository_id"`
|
||||
BranchSlug string `json:"branch_slug,omitempty"`
|
||||
WorktreeID string `json:"worktree_id,omitempty"`
|
||||
WorktreeBranch string `json:"worktree_branch,omitempty"`
|
||||
WorktreePath string `json:"worktree_path,omitempty"`
|
||||
|
||||
@@ -262,6 +262,7 @@ func buildWorktreeCreateRequest(req *EnvPrepareRequest) worktree.CreateRequest {
|
||||
TaskDirName: req.TaskDirName,
|
||||
RepoName: req.RepoName,
|
||||
BranchSlug: req.BranchSlug,
|
||||
BranchIdentitySlug: req.BranchIdentitySlug,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,9 +373,12 @@ func (p *WorktreePreparer) prepareMultiRepo(
|
||||
Duration: time.Since(start),
|
||||
}, nil
|
||||
}
|
||||
createdIDs = append(createdIDs, wt.ID)
|
||||
if spec.WorktreeID == "" || wt.ID != spec.WorktreeID {
|
||||
createdIDs = append(createdIDs, wt.ID)
|
||||
}
|
||||
worktrees = append(worktrees, RepoWorktreeResult{
|
||||
RepositoryID: spec.RepositoryID,
|
||||
BranchSlug: repoBranchIdentitySlug(spec),
|
||||
WorktreeID: wt.ID,
|
||||
WorktreeBranch: wt.Branch,
|
||||
WorktreePath: wt.Path,
|
||||
@@ -451,6 +455,7 @@ func (p *WorktreePreparer) prepareOneRepo(
|
||||
subReq.WorktreeBranchPrefix = spec.WorktreeBranchPrefix
|
||||
subReq.PullBeforeWorktree = spec.PullBeforeWorktree
|
||||
subReq.BranchSlug = spec.BranchSlug
|
||||
subReq.BranchIdentitySlug = repoBranchIdentitySlug(spec)
|
||||
// Strip the multi-repo list to avoid re-entering the multi-repo branch.
|
||||
subReq.Repositories = nil
|
||||
|
||||
@@ -490,6 +495,13 @@ func (p *WorktreePreparer) prepareOneRepo(
|
||||
return wt, steps, stepIdx, nil
|
||||
}
|
||||
|
||||
func repoBranchIdentitySlug(spec RepoPrepareSpec) string {
|
||||
if spec.BranchIdentitySlug != "" {
|
||||
return spec.BranchIdentitySlug
|
||||
}
|
||||
return spec.BranchSlug
|
||||
}
|
||||
|
||||
// rollbackWorktrees removes any worktrees created during a failed multi-repo
|
||||
// preparation. Best effort: failures are logged and do not interrupt teardown.
|
||||
func (p *WorktreePreparer) rollbackWorktrees(ctx context.Context, ids []string) {
|
||||
|
||||
@@ -170,6 +170,12 @@ func initBareGitRepo(t *testing.T, name string) string {
|
||||
}
|
||||
|
||||
func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) {
|
||||
t.Helper()
|
||||
preparer, mgr, _ := newPreparerForTestWithStore(t)
|
||||
return preparer, mgr
|
||||
}
|
||||
|
||||
func newPreparerForTestWithStore(t *testing.T) (*WorktreePreparer, *worktree.Manager, *inMemoryWorktreeStore) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
cfg := worktree.Config{
|
||||
@@ -183,7 +189,7 @@ func newPreparerForTest(t *testing.T) (*WorktreePreparer, *worktree.Manager) {
|
||||
if err != nil {
|
||||
t.Fatalf("worktree manager: %v", err)
|
||||
}
|
||||
return NewWorktreePreparer(mgr, log), mgr
|
||||
return NewWorktreePreparer(mgr, log), mgr, store
|
||||
}
|
||||
|
||||
func TestWorktreePreparer_MultiRepo_CreatesWorktreePerRepo(t *testing.T) {
|
||||
@@ -280,6 +286,123 @@ func TestWorktreePreparer_MultiRepo_RollbackOnPartialFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorktreePreparer_MultiRepo_RollbackKeepsReusedWorktrees(t *testing.T) {
|
||||
repoExisting := initBareGitRepo(t, "existing")
|
||||
repoNew := initBareGitRepo(t, "new")
|
||||
repoBad := t.TempDir()
|
||||
|
||||
preparer, mgr, store := newPreparerForTestWithStore(t)
|
||||
existingPath := filepath.Join(t.TempDir(), "existing-worktree")
|
||||
if err := os.MkdirAll(existingPath, 0755); err != nil {
|
||||
t.Fatalf("mkdir existing worktree: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(existingPath, ".git"), []byte("gitdir: /tmp/existing.git\n"), 0644); err != nil {
|
||||
t.Fatalf("write existing .git file: %v", err)
|
||||
}
|
||||
if err := store.CreateWorktree(context.Background(), &worktree.Worktree{
|
||||
ID: "wt-existing",
|
||||
SessionID: "source-session",
|
||||
TaskID: "task-reuse-fail",
|
||||
RepositoryID: "repo-existing",
|
||||
BranchSlug: "main",
|
||||
Path: existingPath,
|
||||
Status: worktree.StatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed existing worktree: %v", err)
|
||||
}
|
||||
|
||||
req := &EnvPrepareRequest{
|
||||
TaskID: "task-reuse-fail",
|
||||
SessionID: "sess-reuse-fail",
|
||||
TaskTitle: "Reuse Then Fail",
|
||||
ExecutorType: executor.NameStandalone,
|
||||
TaskDirName: "reuse-fail_ccc",
|
||||
Repositories: []RepoPrepareSpec{
|
||||
{
|
||||
RepositoryID: "repo-existing",
|
||||
RepositoryPath: repoExisting,
|
||||
RepoName: "existing",
|
||||
BaseBranch: "main",
|
||||
WorktreeID: "wt-existing",
|
||||
BranchIdentitySlug: "main",
|
||||
},
|
||||
{RepositoryID: "repo-new", RepositoryPath: repoNew, RepoName: "new", BaseBranch: "main"},
|
||||
{RepositoryID: "repo-bad", RepositoryPath: repoBad, RepoName: "bad", BaseBranch: "main"},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := preparer.Prepare(context.Background(), req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare returned hard error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatal("expected failure when final repo is not a git repo")
|
||||
}
|
||||
|
||||
existing, err := store.GetWorktreeByID(context.Background(), "wt-existing")
|
||||
if err != nil {
|
||||
t.Fatalf("get existing worktree: %v", err)
|
||||
}
|
||||
if existing == nil || existing.Status != worktree.StatusActive {
|
||||
t.Fatalf("reused worktree was rolled back: %+v", existing)
|
||||
}
|
||||
if !mgr.IsValid(existingPath) {
|
||||
t.Fatalf("reused worktree path should remain valid: %s", existingPath)
|
||||
}
|
||||
|
||||
all, err := mgr.GetAllByTaskID(context.Background(), "task-reuse-fail")
|
||||
if err != nil {
|
||||
t.Fatalf("list worktrees: %v", err)
|
||||
}
|
||||
for _, wt := range all {
|
||||
if wt.ID != "wt-existing" && wt.Status == worktree.StatusActive {
|
||||
t.Fatalf("new worktree %s should have been rolled back", wt.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorktreePreparer_MultiRepo_RollbackRemovesWorktreeCreatedForStaleReuseID(t *testing.T) {
|
||||
repoNew := initBareGitRepo(t, "stale-new")
|
||||
repoBad := t.TempDir()
|
||||
|
||||
preparer, mgr := newPreparerForTest(t)
|
||||
req := &EnvPrepareRequest{
|
||||
TaskID: "task-stale-reuse-fail",
|
||||
SessionID: "sess-stale-reuse-fail",
|
||||
TaskTitle: "Stale Reuse Then Fail",
|
||||
ExecutorType: executor.NameStandalone,
|
||||
TaskDirName: "stale-reuse-fail_ddd",
|
||||
Repositories: []RepoPrepareSpec{
|
||||
{
|
||||
RepositoryID: "repo-new",
|
||||
RepositoryPath: repoNew,
|
||||
RepoName: "stale-new",
|
||||
BaseBranch: "main",
|
||||
WorktreeID: "wt-stale",
|
||||
},
|
||||
{RepositoryID: "repo-bad", RepositoryPath: repoBad, RepoName: "bad", BaseBranch: "main"},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := preparer.Prepare(context.Background(), req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare returned hard error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatal("expected failure when final repo is not a git repo")
|
||||
}
|
||||
|
||||
all, err := mgr.GetAllByTaskID(context.Background(), "task-stale-reuse-fail")
|
||||
if err != nil {
|
||||
t.Fatalf("list worktrees: %v", err)
|
||||
}
|
||||
for _, wt := range all {
|
||||
if wt.Status == worktree.StatusActive {
|
||||
t.Fatalf("worktree %s should have been rolled back after stale reuse ID created it", wt.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorktreePreparer_MultiRepo_RunsPerRepoSetupScript(t *testing.T) {
|
||||
repoA := initBareGitRepo(t, "frontend")
|
||||
repoB := initBareGitRepo(t, "backend")
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/settings/cliflags"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
)
|
||||
|
||||
// resolveAgentProfile resolves the agent profile and returns the agent type name and profile info.
|
||||
@@ -155,7 +156,9 @@ func collectBaseBranches(req *LaunchRequest) map[string]string {
|
||||
if spec.BaseBranch == "" {
|
||||
continue
|
||||
}
|
||||
out[spec.RepoName] = spec.BaseBranch
|
||||
if key := baseBranchMetadataKey(spec); key != "" {
|
||||
out[key] = spec.BaseBranch
|
||||
}
|
||||
}
|
||||
if req.BaseBranch != "" {
|
||||
if _, ok := out[""]; !ok {
|
||||
@@ -168,6 +171,18 @@ func collectBaseBranches(req *LaunchRequest) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func baseBranchMetadataKey(spec RepoLaunchSpec) string {
|
||||
repoName := worktree.SanitizeRepoDirName(spec.RepoName)
|
||||
if repoName == "" {
|
||||
repoName = spec.RepoName
|
||||
}
|
||||
branchSlug := worktree.SanitizeBranchSlug(spec.BranchSlug)
|
||||
if branchSlug == "" {
|
||||
return repoName
|
||||
}
|
||||
return repoName + "-" + branchSlug
|
||||
}
|
||||
|
||||
// agentCommands holds the initial and continue command strings for an agent execution.
|
||||
type agentCommands struct {
|
||||
initial string
|
||||
@@ -645,6 +660,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e
|
||||
TaskDirName: req.TaskDirName,
|
||||
RepoName: req.RepoName,
|
||||
BranchSlug: req.BranchSlug,
|
||||
BranchIdentitySlug: req.BranchIdentitySlug,
|
||||
Env: req.Env,
|
||||
}
|
||||
// Multi-repo: forward the repo list when the launch request carries one.
|
||||
@@ -670,6 +686,7 @@ func buildEnvPrepareRequest(req *LaunchRequest, workspacePath string, execName e
|
||||
PullBeforeWorktree: r.PullBeforeWorktree,
|
||||
RepoSetupScript: setup,
|
||||
BranchSlug: r.BranchSlug,
|
||||
BranchIdentitySlug: r.BranchIdentitySlug,
|
||||
})
|
||||
}
|
||||
prepReq.Repositories = specs
|
||||
|
||||
@@ -48,6 +48,37 @@ func (a *resumeTestAgent) Runtime() *agents.RuntimeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEnvPrepareRequest_CarriesTopLevelBranchIdentity(t *testing.T) {
|
||||
req := &LaunchRequest{
|
||||
TaskID: "task-1",
|
||||
SessionID: "session-1",
|
||||
RepositoryID: "repo-1",
|
||||
RepositoryPath: "/repos/repo-1",
|
||||
RepoName: "repo-1",
|
||||
BaseBranch: "feature/x",
|
||||
BranchSlug: "feature-x",
|
||||
BranchIdentitySlug: "feature-x",
|
||||
UseWorktree: true,
|
||||
}
|
||||
|
||||
prepReq := buildEnvPrepareRequest(req, "/workspace", executor.NameStandalone)
|
||||
|
||||
if prepReq.BranchSlug != "feature-x" {
|
||||
t.Fatalf("BranchSlug = %q, want feature-x", prepReq.BranchSlug)
|
||||
}
|
||||
if prepReq.BranchIdentitySlug != "feature-x" {
|
||||
t.Fatalf("BranchIdentitySlug = %q, want feature-x", prepReq.BranchIdentitySlug)
|
||||
}
|
||||
specs := prepReq.RepoSpecs()
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("RepoSpecs length = %d, want 1", len(specs))
|
||||
}
|
||||
if specs[0].BranchIdentitySlug != "feature-x" || specs[0].BranchSlug != "feature-x" {
|
||||
t.Fatalf("repo spec branch fields = identity %q path %q, want feature-x/feature-x",
|
||||
specs[0].BranchIdentitySlug, specs[0].BranchSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentCommand_ResumeFlag(t *testing.T) {
|
||||
mgr := newTestManager(t)
|
||||
canRecoverTrue := true
|
||||
|
||||
@@ -308,9 +308,14 @@ type RepoLaunchSpec struct {
|
||||
RepoSetupScript string // Repository-level setup script (optional)
|
||||
RepoCleanupScript string // Repository-level cleanup script (optional)
|
||||
CopyFiles string // Comma-separated paths/globs to copy from the source repo (gitignored .env / config files)
|
||||
// BranchSlug, when set, nests the worktree under {RepoName}/{BranchSlug}/
|
||||
// so multi-branch tasks (same repo, multiple branches) don't collide.
|
||||
// BranchSlug, when set, suffixes the worktree directory as
|
||||
// {RepoName}-{BranchSlug} so multi-branch tasks (same repo, multiple
|
||||
// branches) don't collide.
|
||||
BranchSlug string
|
||||
// BranchIdentitySlug is the stable branch key used for worktree reuse and
|
||||
// persisted environment metadata. It may differ from BranchSlug when a
|
||||
// primary branch keeps the flat legacy path.
|
||||
BranchIdentitySlug string
|
||||
}
|
||||
|
||||
// RouteOverride carries a fully resolved provider profile for one
|
||||
@@ -387,7 +392,10 @@ type LaunchRequest struct {
|
||||
// Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/
|
||||
TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12")
|
||||
RepoName string // Repository name used as subdirectory inside the task directory
|
||||
BranchSlug string // Optional branch subdir for multi-branch tasks (legacy single-repo path)
|
||||
BranchSlug string // Optional branch directory suffix for multi-branch tasks
|
||||
// BranchIdentitySlug is the stable branch key used for single-repo reuse.
|
||||
// It may be non-empty when BranchSlug is empty to preserve a flat path.
|
||||
BranchIdentitySlug string
|
||||
|
||||
// Repositories carries one entry per repository when the launch is multi-repo.
|
||||
// When non-empty it is the source of truth; the legacy single-repo top-level
|
||||
@@ -420,6 +428,7 @@ func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec {
|
||||
PullBeforeWorktree: r.PullBeforeWorktree,
|
||||
CopyFiles: r.CopyFiles,
|
||||
BranchSlug: r.BranchSlug,
|
||||
BranchIdentitySlug: r.BranchIdentitySlug,
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) {
|
||||
WorktreeBranchPrefix: "feat/",
|
||||
PullBeforeWorktree: true,
|
||||
RepoName: "x",
|
||||
BranchSlug: "feature-y",
|
||||
BranchIdentitySlug: "feature-y",
|
||||
}
|
||||
specs := req.RepoSpecs()
|
||||
if len(specs) != 1 {
|
||||
@@ -42,7 +44,8 @@ func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) {
|
||||
if got.RepositoryID != "repo-x" || got.RepositoryPath != "/x" ||
|
||||
got.BaseBranch != "main" || got.CheckoutBranch != "feature/y" ||
|
||||
got.WorktreeID != "wt-1" || got.WorktreeBranchPrefix != "feat/" ||
|
||||
!got.PullBeforeWorktree || got.RepoName != "x" {
|
||||
!got.PullBeforeWorktree || got.RepoName != "x" ||
|
||||
got.BranchSlug != "feature-y" || got.BranchIdentitySlug != "feature-y" {
|
||||
t.Errorf("synthesized spec mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -70,10 +73,12 @@ func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) {
|
||||
|
||||
func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) {
|
||||
req := &EnvPrepareRequest{
|
||||
RepositoryID: "r1",
|
||||
RepositoryPath: "/r1",
|
||||
BaseBranch: "main",
|
||||
RepoSetupScript: "make install",
|
||||
RepositoryID: "r1",
|
||||
RepositoryPath: "/r1",
|
||||
BaseBranch: "main",
|
||||
RepoSetupScript: "make install",
|
||||
BranchSlug: "feature-y",
|
||||
BranchIdentitySlug: "feature-y",
|
||||
}
|
||||
specs := req.RepoSpecs()
|
||||
if len(specs) != 1 {
|
||||
@@ -82,6 +87,9 @@ func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testin
|
||||
if specs[0].RepoSetupScript != "make install" {
|
||||
t.Errorf("repo setup script not propagated: %+v", specs[0])
|
||||
}
|
||||
if specs[0].BranchSlug != "feature-y" || specs[0].BranchIdentitySlug != "feature-y" {
|
||||
t.Errorf("branch identity not propagated: %+v", specs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) {
|
||||
|
||||
@@ -120,8 +120,10 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
|
||||
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
|
||||
PullBeforeWorktree: req.PullBeforeWorktree,
|
||||
// Task directory mode
|
||||
TaskDirName: req.TaskDirName,
|
||||
RepoName: req.RepoName,
|
||||
TaskDirName: req.TaskDirName,
|
||||
RepoName: req.RepoName,
|
||||
BranchSlug: req.BranchSlug,
|
||||
BranchIdentitySlug: req.BranchIdentitySlug,
|
||||
}
|
||||
|
||||
if req.RouteOverride != nil {
|
||||
@@ -155,6 +157,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
|
||||
RepoCleanupScript: r.RepoCleanupScript,
|
||||
CopyFiles: r.CopyFiles,
|
||||
BranchSlug: r.BranchSlug,
|
||||
BranchIdentitySlug: r.BranchIdentitySlug,
|
||||
})
|
||||
}
|
||||
launchReq.Repositories = specs
|
||||
@@ -188,6 +191,7 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch
|
||||
for _, w := range execution.PrepareResult.Worktrees {
|
||||
worktrees = append(worktrees, executor.RepoWorktreeResult{
|
||||
RepositoryID: w.RepositoryID,
|
||||
BranchSlug: w.BranchSlug,
|
||||
WorktreeID: w.WorktreeID,
|
||||
WorktreeBranch: w.WorktreeBranch,
|
||||
WorktreePath: w.WorktreePath,
|
||||
|
||||
@@ -40,6 +40,7 @@ type executorStore interface {
|
||||
ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error)
|
||||
// Session worktree
|
||||
CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error
|
||||
ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error)
|
||||
// Repository entity
|
||||
GetRepository(ctx context.Context, id string) (*models.Repository, error)
|
||||
// Executor
|
||||
@@ -59,6 +60,7 @@ type executorStore interface {
|
||||
UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error
|
||||
CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error
|
||||
ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error)
|
||||
UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error
|
||||
// Session history + plan (for context handover)
|
||||
ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error)
|
||||
GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error)
|
||||
@@ -311,6 +313,11 @@ type LaunchAgentRequest struct {
|
||||
// Task directory mode: place worktree at ~/.kandev/tasks/{TaskDirName}/{RepoName}/
|
||||
TaskDirName string // Semantic task directory name (e.g. "fix-bug_ab12")
|
||||
RepoName string // Repository name used as subdirectory inside the task directory
|
||||
// BranchSlug, when non-empty, suffixes the top-level single-repo path.
|
||||
BranchSlug string
|
||||
// BranchIdentitySlug is the stable branch key for top-level single-repo
|
||||
// reuse. It may be non-empty when BranchSlug is empty to preserve a flat path.
|
||||
BranchIdentitySlug string
|
||||
|
||||
// Repositories carries one entry per repository when the launch is multi-repo.
|
||||
// When non-empty it is the source of truth and the legacy single-repo
|
||||
@@ -343,11 +350,16 @@ type RepoSpec struct {
|
||||
RepoSetupScript string
|
||||
RepoCleanupScript string
|
||||
CopyFiles string
|
||||
// BranchSlug, when non-empty, nests the worktree under the repo dir so
|
||||
// the same repo can host multiple branches within one task. Set by the
|
||||
// BranchSlug, when non-empty, suffixes the repo dir so the same repo can
|
||||
// host multiple branch worktrees as siblings within one task. Set by the
|
||||
// orchestrator when buildRepoSpecs detects multiple rows sharing a
|
||||
// RepositoryID; empty otherwise to preserve the single-branch layout.
|
||||
BranchSlug string
|
||||
|
||||
// BranchIdentitySlug is the stable branch key used for worktree reuse and
|
||||
// persisted environment metadata. It may be non-empty even when BranchSlug
|
||||
// is empty so the primary branch can keep the legacy flat path.
|
||||
BranchIdentitySlug string
|
||||
}
|
||||
|
||||
// McpModeConfig activates config-mode MCP tools (workflow steps, agents, MCP
|
||||
@@ -424,6 +436,7 @@ type LaunchAgentResponse struct {
|
||||
// API surface. One entry per repository prepared during a multi-repo launch.
|
||||
type RepoWorktreeResult struct {
|
||||
RepositoryID string
|
||||
BranchSlug string
|
||||
WorktreeID string
|
||||
WorktreeBranch string
|
||||
WorktreePath string
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -39,7 +40,14 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen
|
||||
return
|
||||
}
|
||||
|
||||
if env.WorktreeID != "" && req.UseWorktree {
|
||||
if env.TaskDirName != "" && req.UseWorktree {
|
||||
req.TaskDirName = env.TaskDirName
|
||||
}
|
||||
|
||||
if req.UseWorktree {
|
||||
e.reuseExistingRepositoryWorktrees(ctx, req, env)
|
||||
}
|
||||
if req.WorktreeID == "" && env.WorktreeID != "" && req.UseWorktree && !hasBranchScopedEnvironmentWorktrees(env) {
|
||||
req.WorktreeID = env.WorktreeID
|
||||
e.logger.Info("reusing existing task environment worktree",
|
||||
zap.String("task_id", req.TaskID),
|
||||
@@ -72,6 +80,250 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryWorktreeKey struct {
|
||||
repositoryID string
|
||||
branchSlug string
|
||||
}
|
||||
|
||||
func (e *Executor) reuseExistingRepositoryWorktrees(ctx context.Context, req *LaunchAgentRequest, env *models.TaskEnvironment) {
|
||||
if !req.UseWorktree {
|
||||
return
|
||||
}
|
||||
repoSpecs := req.Repositories
|
||||
usingTopLevelRepo := false
|
||||
if len(repoSpecs) == 0 {
|
||||
spec, ok := topLevelLaunchRepoSpec(req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
repoSpecs = []RepoSpec{spec}
|
||||
usingTopLevelRepo = true
|
||||
}
|
||||
|
||||
if len(repoSpecs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
envWorktreeIDs := e.environmentRepoWorktreeIDs(req, env)
|
||||
sessionWorktreeIDs := e.latestSessionWorktreeIDsForEnvironment(ctx, req.TaskID, env.ID)
|
||||
if len(envWorktreeIDs) == 0 && len(sessionWorktreeIDs) == 0 {
|
||||
return
|
||||
}
|
||||
hasScopedWorktrees := hasBranchScopedEnvironmentWorktrees(env)
|
||||
allowLegacyEmptyBranchFallback := !hasScopedWorktrees
|
||||
for i := range repoSpecs {
|
||||
spec := &repoSpecs[i]
|
||||
if id := reusableWorktreeIDForSpec(*spec, envWorktreeIDs, sessionWorktreeIDs, allowLegacyEmptyBranchFallback); id != "" {
|
||||
spec.WorktreeID = id
|
||||
}
|
||||
}
|
||||
if repoSpecs[0].WorktreeID == "" {
|
||||
routeUnmatchedTopLevelRepoToScopedPath(req, repoSpecs[0], usingTopLevelRepo, hasScopedWorktrees)
|
||||
return
|
||||
}
|
||||
req.WorktreeID = repoSpecs[0].WorktreeID
|
||||
if usingTopLevelRepo {
|
||||
routeMatchedTopLevelRepoToScopedIdentity(req, repoSpecs[0], hasScopedWorktrees)
|
||||
} else {
|
||||
req.Repositories = repoSpecs
|
||||
}
|
||||
}
|
||||
|
||||
func reusableWorktreeIDForSpec(
|
||||
spec RepoSpec,
|
||||
envWorktreeIDs map[repositoryWorktreeKey]string,
|
||||
sessionWorktreeIDs map[repositoryWorktreeKey]string,
|
||||
allowLegacyEmptyBranchFallback bool,
|
||||
) string {
|
||||
branchIdentity := launchRepoBranchIdentitySlug(spec)
|
||||
key := repositoryWorktreeKey{
|
||||
repositoryID: spec.RepositoryID,
|
||||
branchSlug: branchIdentity,
|
||||
}
|
||||
if id := envWorktreeIDs[key]; id != "" {
|
||||
return id
|
||||
}
|
||||
if id := sessionWorktreeIDs[key]; id != "" {
|
||||
return id
|
||||
}
|
||||
if spec.BranchSlug != "" || !allowLegacyEmptyBranchFallback {
|
||||
return ""
|
||||
}
|
||||
legacyKey := repositoryWorktreeKey{
|
||||
repositoryID: spec.RepositoryID,
|
||||
branchSlug: "",
|
||||
}
|
||||
if id := envWorktreeIDs[legacyKey]; id != "" {
|
||||
return id
|
||||
}
|
||||
if branchIdentity == "" {
|
||||
if id := sessionWorktreeIDs[legacyKey]; id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func routeUnmatchedTopLevelRepoToScopedPath(
|
||||
req *LaunchAgentRequest,
|
||||
spec RepoSpec,
|
||||
usingTopLevelRepo bool,
|
||||
hasScopedWorktrees bool,
|
||||
) {
|
||||
if !usingTopLevelRepo || !hasScopedWorktrees {
|
||||
return
|
||||
}
|
||||
pathSlug := launchRepoBranchIdentitySlug(spec)
|
||||
if pathSlug == "" {
|
||||
return
|
||||
}
|
||||
spec.BranchIdentitySlug = pathSlug
|
||||
spec.BranchSlug = pathSlug
|
||||
req.BranchIdentitySlug = pathSlug
|
||||
req.BranchSlug = pathSlug
|
||||
req.Repositories = []RepoSpec{spec}
|
||||
}
|
||||
|
||||
func routeMatchedTopLevelRepoToScopedIdentity(req *LaunchAgentRequest, spec RepoSpec, hasScopedWorktrees bool) {
|
||||
if !hasScopedWorktrees {
|
||||
return
|
||||
}
|
||||
identitySlug := launchRepoBranchIdentitySlug(spec)
|
||||
if identitySlug == "" {
|
||||
return
|
||||
}
|
||||
spec.BranchIdentitySlug = identitySlug
|
||||
req.BranchIdentitySlug = identitySlug
|
||||
req.Repositories = []RepoSpec{spec}
|
||||
}
|
||||
|
||||
func topLevelLaunchRepoSpec(req *LaunchAgentRequest) (RepoSpec, bool) {
|
||||
if req.RepositoryID == "" {
|
||||
return RepoSpec{}, false
|
||||
}
|
||||
return RepoSpec{
|
||||
RepositoryID: req.RepositoryID,
|
||||
RepositoryPath: req.RepositoryPath,
|
||||
RepositoryURL: req.RepositoryURL,
|
||||
RepoName: req.RepoName,
|
||||
BaseBranch: req.BaseBranch,
|
||||
DefaultBranch: req.DefaultBranch,
|
||||
CheckoutBranch: req.CheckoutBranch,
|
||||
PRNumber: req.PRNumber,
|
||||
WorktreeID: req.WorktreeID,
|
||||
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
|
||||
PullBeforeWorktree: req.PullBeforeWorktree,
|
||||
CopyFiles: req.CopyFiles,
|
||||
BranchIdentitySlug: topLevelBranchIdentitySlug(req),
|
||||
}, true
|
||||
}
|
||||
|
||||
func topLevelBranchIdentitySlug(req *LaunchAgentRequest) string {
|
||||
branch := req.CheckoutBranch
|
||||
if branch == "" {
|
||||
branch = req.BaseBranch
|
||||
}
|
||||
if branch == "" {
|
||||
branch = req.DefaultBranch
|
||||
}
|
||||
return worktree.SanitizeBranchSlug(branch)
|
||||
}
|
||||
|
||||
func launchRepoBranchIdentitySlug(spec RepoSpec) string {
|
||||
if spec.BranchIdentitySlug != "" {
|
||||
return worktree.SanitizeBranchSlug(spec.BranchIdentitySlug)
|
||||
}
|
||||
return worktree.SanitizeBranchSlug(spec.BranchSlug)
|
||||
}
|
||||
|
||||
func (e *Executor) environmentRepoWorktreeIDs(req *LaunchAgentRequest, env *models.TaskEnvironment) map[repositoryWorktreeKey]string {
|
||||
result := make(map[repositoryWorktreeKey]string)
|
||||
if env.WorktreeID != "" && !hasBranchScopedEnvironmentWorktrees(env) {
|
||||
repoID := env.RepositoryID
|
||||
if repoID == "" {
|
||||
repoID = req.RepositoryID
|
||||
}
|
||||
if repoID != "" {
|
||||
result[repositoryWorktreeKey{
|
||||
repositoryID: repoID,
|
||||
branchSlug: "",
|
||||
}] = env.WorktreeID
|
||||
}
|
||||
}
|
||||
for _, repo := range env.Repos {
|
||||
if repo.RepositoryID == "" || repo.WorktreeID == "" {
|
||||
continue
|
||||
}
|
||||
result[repositoryWorktreeKey{
|
||||
repositoryID: repo.RepositoryID,
|
||||
branchSlug: worktree.SanitizeBranchSlug(repo.BranchSlug),
|
||||
}] = repo.WorktreeID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasBranchScopedEnvironmentWorktrees(env *models.TaskEnvironment) bool {
|
||||
for _, repo := range env.Repos {
|
||||
if repo.RepositoryID != "" && repo.WorktreeID != "" && worktree.SanitizeBranchSlug(repo.BranchSlug) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Executor) latestSessionWorktreeIDsForEnvironment(ctx context.Context, taskID, envID string) map[repositoryWorktreeKey]string {
|
||||
sessions, err := e.repo.ListTaskSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
e.logger.Warn("failed to list sessions for per-repo worktree reuse",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("task_environment_id", envID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(sessions, func(i, j int) bool {
|
||||
if !sessions[i].StartedAt.Equal(sessions[j].StartedAt) {
|
||||
return sessions[i].StartedAt.After(sessions[j].StartedAt)
|
||||
}
|
||||
if !sessions[i].UpdatedAt.Equal(sessions[j].UpdatedAt) {
|
||||
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
|
||||
}
|
||||
return sessions[i].ID > sessions[j].ID
|
||||
})
|
||||
for _, session := range sessions {
|
||||
if envID != "" && session.TaskEnvironmentID != "" && session.TaskEnvironmentID != envID {
|
||||
continue
|
||||
}
|
||||
worktrees, err := e.repo.ListTaskSessionWorktrees(ctx, session.ID)
|
||||
if err != nil {
|
||||
e.logger.Warn("failed to list session worktrees for reuse",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
result := sessionWorktreeIDsByKey(worktrees)
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionWorktreeIDsByKey(worktrees []*models.TaskSessionWorktree) map[repositoryWorktreeKey]string {
|
||||
result := make(map[repositoryWorktreeKey]string, len(worktrees))
|
||||
for _, wt := range worktrees {
|
||||
if wt.RepositoryID == "" || wt.WorktreeID == "" {
|
||||
continue
|
||||
}
|
||||
key := repositoryWorktreeKey{
|
||||
repositoryID: wt.RepositoryID,
|
||||
branchSlug: worktree.SanitizeBranchSlug(wt.BranchSlug),
|
||||
}
|
||||
result[key] = wt.WorktreeID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Executor) latestExecutorRunningForEnvironment(ctx context.Context, taskID string, env *models.TaskEnvironment) *models.ExecutorRunning {
|
||||
sessions, err := e.repo.ListTaskSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
|
||||
@@ -48,6 +48,39 @@ func TestReuseExistingEnvironment_WorktreeReuse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_WorktreeReuseKeepsTaskDirName(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
e := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-1",
|
||||
UseWorktree: true,
|
||||
TaskDirName: "fresh-task-dir",
|
||||
Repositories: []RepoSpec{
|
||||
{RepositoryID: "repo-kandev", BranchIdentitySlug: "main"},
|
||||
{RepositoryID: "repo-docs", BranchIdentitySlug: "main"},
|
||||
},
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-existing",
|
||||
TaskDirName: "persisted-task-dir",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{TaskEnvironmentID: "env-existing", RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-kandev"},
|
||||
},
|
||||
}
|
||||
|
||||
e.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.TaskDirName != "persisted-task-dir" {
|
||||
t.Fatalf("TaskDirName = %q, want persisted-task-dir", req.TaskDirName)
|
||||
}
|
||||
if req.Repositories[0].WorktreeID != "wt-kandev" {
|
||||
t.Fatalf("first repo WorktreeID = %q, want wt-kandev", req.Repositories[0].WorktreeID)
|
||||
}
|
||||
if req.Repositories[1].WorktreeID != "" {
|
||||
t.Fatalf("second repo WorktreeID = %q, want empty for new checkout", req.Repositories[1].WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SkipsReuseOnExecutorTypeMismatch(t *testing.T) {
|
||||
// Switching the task's executor profile to a different type must invalidate
|
||||
// reuse: stale PreviousExecutionID/ContainerID/sprite_name from the old
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -706,20 +708,14 @@ func (e *Executor) applyContainerCredentials(ctx context.Context, req *LaunchAge
|
||||
|
||||
// buildRepoSpecs converts resolved repoInfos into per-repo launch specs for
|
||||
// the lifecycle layer. Used only when the task has more than one repository.
|
||||
// When the same RepositoryID appears more than once, the FIRST occurrence
|
||||
// keeps the flat layout (<task>/<repo>/) and subsequent occurrences nest
|
||||
// under <task>/<repo>/<branch-slug>/. This preserves the legacy single-
|
||||
// branch path for any worktree that already exists on disk, so a task
|
||||
// upgraded from single-branch to multi-branch doesn't orphan its primary
|
||||
// worktree directory.
|
||||
// When the same RepositoryID appears more than once, each row gets a stable
|
||||
// BranchIdentitySlug for reuse while the lowest-position branch keeps the flat
|
||||
// layout (<task>/<repo>/). Other branches use sibling directories like
|
||||
// <task>/<repo>-<branch-slug>/.
|
||||
// This preserves the legacy single-branch path when a task later gains another
|
||||
// branch of the same repository.
|
||||
func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec {
|
||||
repoCounts := make(map[string]int, len(allRepos))
|
||||
for _, info := range allRepos {
|
||||
if info.RepositoryID != "" {
|
||||
repoCounts[info.RepositoryID]++
|
||||
}
|
||||
}
|
||||
seenCount := make(map[string]int, len(allRepos))
|
||||
branchPlans := buildRepoBranchPlans(allRepos)
|
||||
out := make([]RepoSpec, 0, len(allRepos))
|
||||
for _, info := range allRepos {
|
||||
spec := RepoSpec{
|
||||
@@ -745,26 +741,131 @@ func buildRepoSpecs(allRepos []*repoInfo) []RepoSpec {
|
||||
spec.RepositoryURL = u
|
||||
}
|
||||
}
|
||||
if repoCounts[info.RepositoryID] > 1 && seenCount[info.RepositoryID] > 0 {
|
||||
slug := worktree.SanitizeBranchSlug(info.CheckoutBranch)
|
||||
if slug == "" {
|
||||
slug = worktree.SanitizeBranchSlug(info.BaseBranch)
|
||||
}
|
||||
// Branches whose names sanitize to "" (or two duplicate rows
|
||||
// without explicit branch names) would otherwise collapse onto
|
||||
// the same on-disk path as the first row. Fall back to a
|
||||
// position-derived slug so siblings get distinct directories.
|
||||
if slug == "" {
|
||||
slug = fmt.Sprintf("branch-%d", seenCount[info.RepositoryID]+1)
|
||||
}
|
||||
spec.BranchSlug = slug
|
||||
if plan, ok := branchPlans[info]; ok {
|
||||
spec.BranchIdentitySlug = plan.identitySlug
|
||||
spec.BranchSlug = plan.pathSlug
|
||||
}
|
||||
seenCount[info.RepositoryID]++
|
||||
out = append(out, spec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type repoBranchPlan struct {
|
||||
identitySlug string
|
||||
pathSlug string
|
||||
}
|
||||
|
||||
func buildRepoBranchPlans(allRepos []*repoInfo) map[*repoInfo]repoBranchPlan {
|
||||
groups := make(map[string][]*repoInfo, len(allRepos))
|
||||
for _, info := range allRepos {
|
||||
if info == nil || info.RepositoryID == "" {
|
||||
continue
|
||||
}
|
||||
groups[info.RepositoryID] = append(groups[info.RepositoryID], info)
|
||||
}
|
||||
|
||||
plans := make(map[*repoInfo]repoBranchPlan, len(allRepos))
|
||||
for repoID, group := range groups {
|
||||
identities := branchIdentitySlugsForGroup(repoID, group)
|
||||
if len(group) < 2 {
|
||||
for _, info := range group {
|
||||
plans[info] = repoBranchPlan{identitySlug: identities[info]}
|
||||
}
|
||||
continue
|
||||
}
|
||||
flatIdentity := selectFlatBranchIdentity(group, identities)
|
||||
for _, info := range group {
|
||||
identity := identities[info]
|
||||
pathSlug := identity
|
||||
if identity == flatIdentity {
|
||||
pathSlug = ""
|
||||
}
|
||||
plans[info] = repoBranchPlan{identitySlug: identity, pathSlug: pathSlug}
|
||||
}
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func branchIdentitySlugsForGroup(repoID string, group []*repoInfo) map[*repoInfo]string {
|
||||
raw := make(map[*repoInfo]string, len(group))
|
||||
counts := make(map[string]int, len(group))
|
||||
for _, info := range group {
|
||||
slug := preferredBranchIdentitySlug(info)
|
||||
if slug == "" {
|
||||
slug = "branch-" + branchIdentityHash(repoID, info)
|
||||
}
|
||||
raw[info] = slug
|
||||
counts[slug]++
|
||||
}
|
||||
|
||||
out := make(map[*repoInfo]string, len(group))
|
||||
for _, info := range group {
|
||||
slug := raw[info]
|
||||
if counts[slug] > 1 {
|
||||
slug += "-" + branchIdentityHash(repoID, info)
|
||||
}
|
||||
slug = worktree.SanitizeBranchSlug(slug)
|
||||
if slug == "" {
|
||||
slug = "branch-" + branchIdentityHash(repoID, info)
|
||||
}
|
||||
out[info] = slug
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func preferredBranchIdentitySlug(info *repoInfo) string {
|
||||
branch := info.CheckoutBranch
|
||||
if branch == "" {
|
||||
branch = info.BaseBranch
|
||||
}
|
||||
if branch == "" && info.Repository != nil {
|
||||
branch = info.Repository.DefaultBranch
|
||||
}
|
||||
return worktree.SanitizeBranchSlug(branch)
|
||||
}
|
||||
|
||||
func branchIdentityHash(repoID string, info *repoInfo) string {
|
||||
seed := strings.Join([]string{
|
||||
repoID,
|
||||
info.BaseBranch,
|
||||
info.CheckoutBranch,
|
||||
fmt.Sprintf("%d", info.PRNumber),
|
||||
}, "\x00")
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(seed))
|
||||
return fmt.Sprintf("%08x", h.Sum32())
|
||||
}
|
||||
|
||||
func selectFlatBranchIdentity(group []*repoInfo, identities map[*repoInfo]string) string {
|
||||
candidates := make([]*repoInfo, 0, len(group))
|
||||
candidates = append(candidates, group...)
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
if candidates[i].Position != candidates[j].Position {
|
||||
return candidates[i].Position < candidates[j].Position
|
||||
}
|
||||
leftRank := flatBranchRank(candidates[i])
|
||||
rightRank := flatBranchRank(candidates[j])
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return identities[candidates[i]] < identities[candidates[j]]
|
||||
})
|
||||
return identities[candidates[0]]
|
||||
}
|
||||
|
||||
func flatBranchRank(info *repoInfo) int {
|
||||
if info.CheckoutBranch != "" {
|
||||
return 3
|
||||
}
|
||||
if info.Repository != nil && info.Repository.DefaultBranch != "" && info.BaseBranch == info.Repository.DefaultBranch {
|
||||
return 0
|
||||
}
|
||||
if info.BaseBranch == defaultBaseBranch {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// applyRepositoryConfig sets repository-related fields on the request and resolves clone URLs.
|
||||
func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, repoInfo *repoInfo, execConfig executorConfig, metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||
if repoInfo.RepositoryPath != "" {
|
||||
@@ -1155,6 +1256,7 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv
|
||||
for i, w := range worktrees {
|
||||
out = append(out, &models.TaskEnvironmentRepo{
|
||||
RepositoryID: w.RepositoryID,
|
||||
BranchSlug: w.BranchSlug,
|
||||
WorktreeID: w.WorktreeID,
|
||||
WorktreePath: w.WorktreePath,
|
||||
WorktreeBranch: w.WorktreeBranch,
|
||||
@@ -1165,9 +1267,10 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv
|
||||
return out
|
||||
}
|
||||
|
||||
// persistTaskEnvironmentRepos inserts per-repo rows under an existing env id,
|
||||
// skipping any (env, repo) pair that already exists. Used when an existing
|
||||
// environment is reused (resume / re-launch on the same task).
|
||||
// persistTaskEnvironmentRepos upserts per-repo rows under an existing env id.
|
||||
// Used when an existing environment is reused (resume / re-launch on the same
|
||||
// task), including cases where stale or legacy rows need the successful launch
|
||||
// result written back for the next handoff.
|
||||
func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, worktrees []RepoWorktreeResult) {
|
||||
if envID == "" || len(worktrees) == 0 {
|
||||
return
|
||||
@@ -1179,17 +1282,36 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
have := make(map[string]bool, len(existing))
|
||||
byKey := make(map[string]*models.TaskEnvironmentRepo, len(existing))
|
||||
legacyFlatByRepo := make(map[string]*models.TaskEnvironmentRepo)
|
||||
for _, row := range existing {
|
||||
have[row.RepositoryID] = true
|
||||
key := row.RepositoryID + "\x00" + row.BranchSlug
|
||||
byKey[key] = row
|
||||
if row.RepositoryID != "" && row.BranchSlug == "" {
|
||||
legacyFlatByRepo[row.RepositoryID] = row
|
||||
}
|
||||
}
|
||||
for i, w := range worktrees {
|
||||
if w.RepositoryID == "" || have[w.RepositoryID] {
|
||||
if w.RepositoryID == "" {
|
||||
continue
|
||||
}
|
||||
key := w.RepositoryID + "\x00" + w.BranchSlug
|
||||
if row := byKey[key]; row != nil {
|
||||
e.refreshTaskEnvironmentRepo(ctx, row, w, i)
|
||||
continue
|
||||
}
|
||||
if w.BranchSlug != "" {
|
||||
if row := legacyFlatByRepo[w.RepositoryID]; row != nil {
|
||||
e.refreshTaskEnvironmentRepo(ctx, row, w, i)
|
||||
delete(legacyFlatByRepo, w.RepositoryID)
|
||||
byKey[key] = row
|
||||
continue
|
||||
}
|
||||
}
|
||||
row := &models.TaskEnvironmentRepo{
|
||||
TaskEnvironmentID: envID,
|
||||
RepositoryID: w.RepositoryID,
|
||||
BranchSlug: w.BranchSlug,
|
||||
WorktreeID: w.WorktreeID,
|
||||
WorktreePath: w.WorktreePath,
|
||||
WorktreeBranch: w.WorktreeBranch,
|
||||
@@ -1205,6 +1327,34 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) refreshTaskEnvironmentRepo(ctx context.Context, row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) {
|
||||
if !taskEnvironmentRepoNeedsRefresh(row, w, position) {
|
||||
return
|
||||
}
|
||||
row.BranchSlug = w.BranchSlug
|
||||
row.WorktreeID = w.WorktreeID
|
||||
row.WorktreePath = w.WorktreePath
|
||||
row.WorktreeBranch = w.WorktreeBranch
|
||||
row.Position = position
|
||||
row.ErrorMessage = w.ErrorMessage
|
||||
if err := e.repo.UpdateTaskEnvironmentRepo(ctx, row); err != nil {
|
||||
e.logger.Warn("failed to update task environment repo",
|
||||
zap.String("env_id", row.TaskEnvironmentID),
|
||||
zap.String("repository_id", row.RepositoryID),
|
||||
zap.String("branch_slug", row.BranchSlug),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func taskEnvironmentRepoNeedsRefresh(row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) bool {
|
||||
return row.BranchSlug != w.BranchSlug ||
|
||||
row.WorktreeID != w.WorktreeID ||
|
||||
row.WorktreePath != w.WorktreePath ||
|
||||
row.WorktreeBranch != w.WorktreeBranch ||
|
||||
row.Position != position ||
|
||||
row.ErrorMessage != w.ErrorMessage
|
||||
}
|
||||
|
||||
// extractSandboxID extracts the sandbox identifier from launch response metadata.
|
||||
// For Sprites executors, this is the sprite_name.
|
||||
func extractSandboxID(metadata map[string]interface{}) string {
|
||||
|
||||
@@ -642,7 +642,13 @@ func (m *mockRepository) GetLastAgentMessage(_ context.Context, _ string) (strin
|
||||
|
||||
// Task Session Worktree operations
|
||||
func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
|
||||
return nil, nil
|
||||
var out []*models.TaskSessionWorktree
|
||||
for _, wt := range m.sessionWorktrees {
|
||||
if wt.SessionID == sessionID {
|
||||
out = append(out, wt)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskSessionWorktree, error) {
|
||||
return make(map[string][]*models.TaskSessionWorktree), nil
|
||||
@@ -819,12 +825,30 @@ func (m *mockRepository) UpdateTaskEnvironment(_ context.Context, env *models.Ta
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) CreateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error {
|
||||
if repo.ID == "" {
|
||||
repo.ID = repo.TaskEnvironmentID + "-repo-" + repo.RepositoryID
|
||||
if repo.BranchSlug != "" {
|
||||
repo.ID += "-branch-" + repo.BranchSlug
|
||||
}
|
||||
}
|
||||
m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(m.taskEnvironmentRepos[repo.TaskEnvironmentID], repo)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) ListTaskEnvironmentRepos(_ context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) {
|
||||
return m.taskEnvironmentRepos[envID], nil
|
||||
}
|
||||
func (m *mockRepository) UpdateTaskEnvironmentRepo(_ context.Context, repo *models.TaskEnvironmentRepo) error {
|
||||
rows := m.taskEnvironmentRepos[repo.TaskEnvironmentID]
|
||||
for i, row := range rows {
|
||||
if row.ID == repo.ID {
|
||||
rows[i] = repo
|
||||
m.taskEnvironmentRepos[repo.TaskEnvironmentID] = rows
|
||||
return nil
|
||||
}
|
||||
}
|
||||
m.taskEnvironmentRepos[repo.TaskEnvironmentID] = append(rows, repo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Task Plan operations
|
||||
func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { return nil }
|
||||
|
||||
@@ -34,6 +34,15 @@ func seedMultiRepoTask(t *testing.T, repo *mockRepository, taskID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func seedWorktreeExecutor(repo *mockRepository) {
|
||||
repo.executors[models.ExecutorIDWorktree] = &models.Executor{
|
||||
ID: models.ExecutorIDWorktree,
|
||||
Type: models.ExecutorTypeWorktree,
|
||||
Status: models.ExecutorStatusActive,
|
||||
Resumable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_MultiRepo_PopulatesRequestRepositories(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-multi-1"
|
||||
@@ -165,6 +174,778 @@ func TestLaunchPreparedSession_MultiRepo_PersistsPerRepoEnvironmentAndWorktreeRo
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_MultiRepo_ReusesPerRepoWorktreeIDsFromEnvironment(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-multi-reuse"
|
||||
sessionID := "session-multi-reuse"
|
||||
seedMultiRepoTask(t, repo, taskID)
|
||||
seedWorktreeExecutor(repo)
|
||||
|
||||
repo.taskEnvironments["env-existing"] = &models.TaskEnvironment{
|
||||
ID: "env-existing",
|
||||
TaskID: taskID,
|
||||
ExecutorType: string(models.ExecutorTypeWorktree),
|
||||
Status: models.TaskEnvironmentStatusReady,
|
||||
WorktreeID: "wt-front",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{
|
||||
TaskEnvironmentID: "env-existing",
|
||||
RepositoryID: "repo-front",
|
||||
WorktreeID: "wt-front",
|
||||
Position: 0,
|
||||
},
|
||||
{
|
||||
TaskEnvironmentID: "env-existing",
|
||||
RepositoryID: "repo-back",
|
||||
WorktreeID: "wt-back",
|
||||
Position: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
repo.taskEnvironmentRepos["env-existing"] = repo.taskEnvironments["env-existing"].Repos
|
||||
repo.sessions[sessionID] = &models.TaskSession{
|
||||
ID: sessionID,
|
||||
TaskID: taskID,
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDWorktree,
|
||||
State: models.TaskSessionStateCreated,
|
||||
StartedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
var captured *LaunchAgentRequest
|
||||
agentManager := &mockAgentManager{
|
||||
launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
captured = req
|
||||
return &LaunchAgentResponse{AgentExecutionID: "exec-reuse"}, nil
|
||||
},
|
||||
}
|
||||
exec := newTestExecutor(t, agentManager, repo)
|
||||
|
||||
task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi"}
|
||||
_, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDWorktree,
|
||||
StartAgent: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchPreparedSession: %v", err)
|
||||
}
|
||||
|
||||
if captured == nil {
|
||||
t.Fatal("expected launch request to be captured")
|
||||
}
|
||||
if len(captured.Repositories) != 2 {
|
||||
t.Fatalf("expected 2 repo specs, got %d", len(captured.Repositories))
|
||||
}
|
||||
if captured.Repositories[0].WorktreeID != "wt-front" {
|
||||
t.Errorf("front WorktreeID = %q, want wt-front", captured.Repositories[0].WorktreeID)
|
||||
}
|
||||
if captured.Repositories[1].WorktreeID != "wt-back" {
|
||||
t.Errorf("back WorktreeID = %q, want wt-back", captured.Repositories[1].WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingRepositoryWorktrees_EnvironmentRowsWinOverSessionRows(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-env-wins"
|
||||
envID := "env-env-wins"
|
||||
now := time.Now().UTC()
|
||||
repo.sessions["session-prev"] = &models.TaskSession{
|
||||
ID: "session-prev",
|
||||
TaskID: taskID,
|
||||
TaskEnvironmentID: envID,
|
||||
StartedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
}
|
||||
repo.sessionWorktrees = append(repo.sessionWorktrees,
|
||||
&models.TaskSessionWorktree{
|
||||
SessionID: "session-prev",
|
||||
RepositoryID: "repo-a",
|
||||
BranchSlug: "main",
|
||||
WorktreeID: "wt-session-a",
|
||||
},
|
||||
&models.TaskSessionWorktree{
|
||||
SessionID: "session-prev",
|
||||
RepositoryID: "repo-b",
|
||||
BranchSlug: "feature",
|
||||
WorktreeID: "wt-session-b",
|
||||
},
|
||||
)
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: taskID,
|
||||
UseWorktree: true,
|
||||
Repositories: []RepoSpec{
|
||||
{RepositoryID: "repo-a", BranchIdentitySlug: "main"},
|
||||
{RepositoryID: "repo-b", BranchIdentitySlug: "feature"},
|
||||
},
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: envID,
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-a", BranchSlug: "main", WorktreeID: "wt-env-a"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingRepositoryWorktrees(context.Background(), req, env)
|
||||
|
||||
if req.Repositories[0].WorktreeID != "wt-env-a" {
|
||||
t.Fatalf("repo-a WorktreeID = %q, want env row to win with wt-env-a", req.Repositories[0].WorktreeID)
|
||||
}
|
||||
if req.Repositories[1].WorktreeID != "wt-session-b" {
|
||||
t.Fatalf("repo-b WorktreeID = %q, want session fallback wt-session-b", req.Repositories[1].WorktreeID)
|
||||
}
|
||||
if req.WorktreeID != "wt-env-a" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want first repo env worktree wt-env-a", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingRepositoryWorktrees_LegacyFlatEnvWorktreeFeedsFlatBranchSpec(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-legacy-flat",
|
||||
RepositoryID: "repo-kandev",
|
||||
UseWorktree: true,
|
||||
Repositories: []RepoSpec{
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchIdentitySlug: "release-1-2",
|
||||
BranchSlug: "",
|
||||
},
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchIdentitySlug: "main",
|
||||
BranchSlug: "main",
|
||||
},
|
||||
},
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-legacy-flat",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-legacy-flat",
|
||||
}
|
||||
|
||||
exec.reuseExistingRepositoryWorktrees(context.Background(), req, env)
|
||||
|
||||
if req.Repositories[0].WorktreeID != "wt-legacy-flat" {
|
||||
t.Fatalf("flat repo WorktreeID = %q, want legacy top-level wt-legacy-flat", req.Repositories[0].WorktreeID)
|
||||
}
|
||||
if req.Repositories[1].WorktreeID != "" {
|
||||
t.Fatalf("nested repo WorktreeID = %q, want empty", req.Repositories[1].WorktreeID)
|
||||
}
|
||||
if req.WorktreeID != "wt-legacy-flat" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want wt-legacy-flat", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingRepositoryWorktrees_LegacyEmptyBranchRowFeedsFlatBranchSpec(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-legacy-empty-row",
|
||||
UseWorktree: true,
|
||||
Repositories: []RepoSpec{
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchIdentitySlug: "main",
|
||||
BranchSlug: "",
|
||||
},
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchIdentitySlug: "feature-x",
|
||||
BranchSlug: "feature-x",
|
||||
},
|
||||
},
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-legacy-empty-row",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "", WorktreeID: "wt-flat-row"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingRepositoryWorktrees(context.Background(), req, env)
|
||||
|
||||
if req.Repositories[0].WorktreeID != "wt-flat-row" {
|
||||
t.Fatalf("flat repo WorktreeID = %q, want legacy row wt-flat-row", req.Repositories[0].WorktreeID)
|
||||
}
|
||||
if req.Repositories[1].WorktreeID != "" {
|
||||
t.Fatalf("nested repo WorktreeID = %q, want empty", req.Repositories[1].WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoUsesBranchScopedEnvRow(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-single-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
BaseBranch: "feature-x",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-single-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "wt-feature" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want branch-scoped wt-feature", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoBranchMatchKeepsScopedRepoSpec(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-single-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
RepoName: "kandev",
|
||||
BaseBranch: "feature-x",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-single-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "wt-feature" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want branch-scoped wt-feature", req.WorktreeID)
|
||||
}
|
||||
if len(req.Repositories) != 1 {
|
||||
t.Fatalf("Repositories length = %d, want one branch-scoped reuse spec", len(req.Repositories))
|
||||
}
|
||||
got := req.Repositories[0]
|
||||
if got.WorktreeID != "wt-feature" {
|
||||
t.Fatalf("spec WorktreeID = %q, want wt-feature", got.WorktreeID)
|
||||
}
|
||||
if got.BranchIdentitySlug != "feature-x" {
|
||||
t.Fatalf("spec BranchIdentitySlug = %q, want feature-x", got.BranchIdentitySlug)
|
||||
}
|
||||
if req.BranchIdentitySlug != "feature-x" {
|
||||
t.Fatalf("top-level BranchIdentitySlug = %q, want feature-x", req.BranchIdentitySlug)
|
||||
}
|
||||
if got.BranchSlug != "" {
|
||||
t.Fatalf("spec BranchSlug = %q, want empty to preserve the existing path", got.BranchSlug)
|
||||
}
|
||||
if req.BranchSlug != "" {
|
||||
t.Fatalf("top-level BranchSlug = %q, want empty to preserve the existing path", req.BranchSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoUsesDefaultBranchScopedEnvRow(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-single-default-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
DefaultBranch: "main",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-single-default-branch-row",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-feature",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "wt-main" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want default-branch wt-main", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoDoesNotFallBackToWrongScopedEnvWorktree(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-single-no-match",
|
||||
RepositoryID: "repo-kandev",
|
||||
BaseBranch: "feature-x",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-single-no-match",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want no stale env-level fallback", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoUnmatchedScopedEnvUsesBranchPathSlug(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: "task-single-new-scoped-branch",
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
RepoName: "kandev",
|
||||
BaseBranch: "feature/new-path",
|
||||
DefaultBranch: "main",
|
||||
WorktreeBranchPrefix: "task/",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: "env-single-new-scoped-branch",
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want no stale env-level fallback", req.WorktreeID)
|
||||
}
|
||||
if len(req.Repositories) != 1 {
|
||||
t.Fatalf("Repositories length = %d, want one branch-scoped prepare spec", len(req.Repositories))
|
||||
}
|
||||
got := req.Repositories[0]
|
||||
if got.BranchSlug != "feature-new-path" {
|
||||
t.Fatalf("BranchSlug = %q, want feature-new-path", got.BranchSlug)
|
||||
}
|
||||
if got.BranchIdentitySlug != "feature-new-path" {
|
||||
t.Fatalf("BranchIdentitySlug = %q, want feature-new-path", got.BranchIdentitySlug)
|
||||
}
|
||||
if req.BranchSlug != "feature-new-path" {
|
||||
t.Fatalf("top-level BranchSlug = %q, want feature-new-path", req.BranchSlug)
|
||||
}
|
||||
if req.BranchIdentitySlug != "feature-new-path" {
|
||||
t.Fatalf("top-level BranchIdentitySlug = %q, want feature-new-path", req.BranchIdentitySlug)
|
||||
}
|
||||
if got.RepositoryPath != req.RepositoryPath || got.RepoName != req.RepoName {
|
||||
t.Fatalf("repo fields not carried into scoped spec: %+v", got)
|
||||
}
|
||||
if got.WorktreeBranchPrefix != req.WorktreeBranchPrefix || got.DefaultBranch != req.DefaultBranch {
|
||||
t.Fatalf("branch defaults not carried into scoped spec: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchWhenEnvIsScoped(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-single-empty-session-branch"
|
||||
envID := "env-single-empty-session-branch"
|
||||
now := time.Now().UTC()
|
||||
repo.sessions["session-prev"] = &models.TaskSession{
|
||||
ID: "session-prev",
|
||||
TaskID: taskID,
|
||||
TaskEnvironmentID: envID,
|
||||
StartedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
}
|
||||
repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{
|
||||
SessionID: "session-prev",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "",
|
||||
WorktreeID: "wt-stale-empty-branch",
|
||||
})
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: taskID,
|
||||
RepositoryID: "repo-kandev",
|
||||
BaseBranch: "feature-x",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: envID,
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"},
|
||||
},
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want no empty-branch session fallback", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchForNewBranch(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-single-empty-session-new-branch"
|
||||
envID := "env-single-empty-session-new-branch"
|
||||
now := time.Now().UTC()
|
||||
repo.sessions["session-prev"] = &models.TaskSession{
|
||||
ID: "session-prev",
|
||||
TaskID: taskID,
|
||||
TaskEnvironmentID: envID,
|
||||
StartedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
}
|
||||
repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{
|
||||
SessionID: "session-prev",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "",
|
||||
WorktreeID: "wt-stale-empty-branch",
|
||||
})
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: taskID,
|
||||
RepositoryID: "repo-kandev",
|
||||
BaseBranch: "newbranch",
|
||||
UseWorktree: true,
|
||||
}
|
||||
env := &models.TaskEnvironment{
|
||||
ID: envID,
|
||||
RepositoryID: "repo-kandev",
|
||||
}
|
||||
|
||||
exec.reuseExistingEnvironment(context.Background(), req, env)
|
||||
|
||||
if req.WorktreeID != "" {
|
||||
t.Fatalf("top-level WorktreeID = %q, want no empty-branch session fallback", req.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-multi-branch-reuse"
|
||||
sessionID := "session-multi-branch-reuse"
|
||||
sourceSessionID := "session-source"
|
||||
now := time.Now().UTC()
|
||||
seedWorktreeExecutor(repo)
|
||||
|
||||
repo.repositories["repo-kandev"] = &models.Repository{
|
||||
ID: "repo-kandev",
|
||||
Name: "kandev",
|
||||
LocalPath: "/repos/kandev",
|
||||
WorktreeBranchPrefix: "feature/",
|
||||
}
|
||||
repo.taskRepositories["tr-main"] = &models.TaskRepository{
|
||||
ID: "tr-main", TaskID: taskID, RepositoryID: "repo-kandev", Position: 0, BaseBranch: "main",
|
||||
}
|
||||
repo.taskRepositories["tr-branch"] = &models.TaskRepository{
|
||||
ID: "tr-branch", TaskID: taskID, RepositoryID: "repo-kandev", Position: 1, BaseBranch: "main", CheckoutBranch: "branch-5hn",
|
||||
}
|
||||
repo.taskEnvironments["env-existing"] = &models.TaskEnvironment{
|
||||
ID: "env-existing",
|
||||
TaskID: taskID,
|
||||
ExecutorType: string(models.ExecutorTypeWorktree),
|
||||
Status: models.TaskEnvironmentStatusReady,
|
||||
WorktreeID: "wt-main",
|
||||
}
|
||||
repo.sessions[sourceSessionID] = &models.TaskSession{
|
||||
ID: sourceSessionID,
|
||||
TaskID: taskID,
|
||||
TaskEnvironmentID: "env-existing",
|
||||
StartedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
}
|
||||
repo.sessions[sessionID] = &models.TaskSession{
|
||||
ID: sessionID,
|
||||
TaskID: taskID,
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDWorktree,
|
||||
State: models.TaskSessionStateCreated,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
repo.sessionWorktrees = append(repo.sessionWorktrees,
|
||||
&models.TaskSessionWorktree{
|
||||
SessionID: sourceSessionID,
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-main",
|
||||
BranchSlug: "main",
|
||||
WorktreePath: "/tasks/t/kandev",
|
||||
WorktreeBranch: "feature/t",
|
||||
},
|
||||
&models.TaskSessionWorktree{
|
||||
SessionID: sourceSessionID,
|
||||
RepositoryID: "repo-kandev",
|
||||
WorktreeID: "wt-branch",
|
||||
BranchSlug: "branch-5hn",
|
||||
WorktreePath: "/tasks/t/kandev-branch-5hn",
|
||||
WorktreeBranch: "branch-5hn",
|
||||
},
|
||||
)
|
||||
|
||||
var captured *LaunchAgentRequest
|
||||
agentManager := &mockAgentManager{
|
||||
launchAgentFunc: func(_ context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
captured = req
|
||||
return &LaunchAgentResponse{AgentExecutionID: "exec-reuse-branch"}, nil
|
||||
},
|
||||
}
|
||||
exec := newTestExecutor(t, agentManager, repo)
|
||||
|
||||
task := &v1.Task{ID: taskID, WorkspaceID: "ws-1", Title: "Multi Branch"}
|
||||
_, err := exec.LaunchPreparedSession(context.Background(), task, sessionID, LaunchOptions{
|
||||
AgentProfileID: "profile-123",
|
||||
ExecutorID: models.ExecutorIDWorktree,
|
||||
StartAgent: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchPreparedSession: %v", err)
|
||||
}
|
||||
|
||||
if captured == nil {
|
||||
t.Fatal("expected launch request to be captured")
|
||||
}
|
||||
if len(captured.Repositories) != 2 {
|
||||
t.Fatalf("expected 2 repo specs, got %d", len(captured.Repositories))
|
||||
}
|
||||
if captured.Repositories[0].WorktreeID != "wt-main" {
|
||||
t.Errorf("main WorktreeID = %q, want wt-main", captured.Repositories[0].WorktreeID)
|
||||
}
|
||||
if captured.Repositories[0].BranchIdentitySlug != "main" {
|
||||
t.Errorf("main BranchIdentitySlug = %q, want main", captured.Repositories[0].BranchIdentitySlug)
|
||||
}
|
||||
if captured.Repositories[0].BranchSlug != "" {
|
||||
t.Errorf("main BranchSlug = %q, want empty flat-path slug", captured.Repositories[0].BranchSlug)
|
||||
}
|
||||
if captured.Repositories[1].BranchSlug != "branch-5hn" {
|
||||
t.Errorf("branch spec BranchSlug = %q, want branch-5hn", captured.Repositories[1].BranchSlug)
|
||||
}
|
||||
if captured.Repositories[1].BranchIdentitySlug != "branch-5hn" {
|
||||
t.Errorf("branch spec BranchIdentitySlug = %q, want branch-5hn", captured.Repositories[1].BranchIdentitySlug)
|
||||
}
|
||||
if captured.Repositories[1].WorktreeID != "wt-branch" {
|
||||
t.Errorf("branch WorktreeID = %q, want wt-branch", captured.Repositories[1].WorktreeID)
|
||||
}
|
||||
if captured.WorktreeID != "wt-main" {
|
||||
t.Errorf("top-level WorktreeID = %q, want wt-main", captured.WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRepoSpecs_MultiBranchIdentityStableAcrossReorder(t *testing.T) {
|
||||
repo := &models.Repository{ID: "repo-kandev", Name: "kandev", DefaultBranch: "main"}
|
||||
mainInfo := &repoInfo{
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
BaseBranch: "main",
|
||||
Position: 0,
|
||||
Repository: repo,
|
||||
}
|
||||
branchInfo := &repoInfo{
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
BaseBranch: "main",
|
||||
CheckoutBranch: "branch-5hn",
|
||||
Position: 1,
|
||||
Repository: repo,
|
||||
}
|
||||
|
||||
first := buildRepoSpecs([]*repoInfo{mainInfo, branchInfo})
|
||||
second := buildRepoSpecs([]*repoInfo{branchInfo, mainInfo})
|
||||
|
||||
if first[0].BranchIdentitySlug != "main" || first[0].BranchSlug != "" {
|
||||
t.Fatalf("first main plan = identity %q path %q, want main/empty", first[0].BranchIdentitySlug, first[0].BranchSlug)
|
||||
}
|
||||
if first[1].BranchIdentitySlug != "branch-5hn" || first[1].BranchSlug != "branch-5hn" {
|
||||
t.Fatalf("first branch plan = identity %q path %q, want branch-5hn/branch-5hn", first[1].BranchIdentitySlug, first[1].BranchSlug)
|
||||
}
|
||||
if second[0].BranchIdentitySlug != "branch-5hn" || second[0].BranchSlug != "branch-5hn" {
|
||||
t.Fatalf("reordered branch plan = identity %q path %q, want branch-5hn/branch-5hn", second[0].BranchIdentitySlug, second[0].BranchSlug)
|
||||
}
|
||||
if second[1].BranchIdentitySlug != "main" || second[1].BranchSlug != "" {
|
||||
t.Fatalf("reordered main plan = identity %q path %q, want main/empty", second[1].BranchIdentitySlug, second[1].BranchSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRepoSpecs_MultiBranchFlatPathFollowsLowestTaskRepoPosition(t *testing.T) {
|
||||
repo := &models.Repository{ID: "repo-kandev", Name: "kandev", DefaultBranch: "main"}
|
||||
releaseInfo := &repoInfo{
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
BaseBranch: "release/1.2",
|
||||
Position: 0,
|
||||
Repository: repo,
|
||||
}
|
||||
mainInfo := &repoInfo{
|
||||
RepositoryID: "repo-kandev",
|
||||
RepositoryPath: "/repos/kandev",
|
||||
BaseBranch: "main",
|
||||
Position: 1,
|
||||
Repository: repo,
|
||||
}
|
||||
|
||||
specs := buildRepoSpecs([]*repoInfo{mainInfo, releaseInfo})
|
||||
|
||||
if specs[0].BranchIdentitySlug != "main" || specs[0].BranchSlug != "main" {
|
||||
t.Fatalf("main plan = identity %q path %q, want main/main", specs[0].BranchIdentitySlug, specs[0].BranchSlug)
|
||||
}
|
||||
if specs[1].BranchIdentitySlug != "release-1.2" || specs[1].BranchSlug != "" {
|
||||
t.Fatalf("release plan = identity %q path %q, want release-1.2/empty", specs[1].BranchIdentitySlug, specs[1].BranchSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRepoSpecs_AssignsBranchIdentityForDistinctRepos(t *testing.T) {
|
||||
frontendInfo := &repoInfo{
|
||||
RepositoryID: "repo-front",
|
||||
RepositoryPath: "/repos/frontend",
|
||||
BaseBranch: "main",
|
||||
Position: 0,
|
||||
Repository: &models.Repository{ID: "repo-front", Name: "frontend", DefaultBranch: "main"},
|
||||
}
|
||||
backendInfo := &repoInfo{
|
||||
RepositoryID: "repo-back",
|
||||
RepositoryPath: "/repos/backend",
|
||||
BaseBranch: "release/1.2",
|
||||
Position: 1,
|
||||
Repository: &models.Repository{ID: "repo-back", Name: "backend", DefaultBranch: "main"},
|
||||
}
|
||||
|
||||
specs := buildRepoSpecs([]*repoInfo{frontendInfo, backendInfo})
|
||||
|
||||
if specs[0].BranchIdentitySlug != "main" || specs[0].BranchSlug != "" {
|
||||
t.Fatalf("frontend plan = identity %q path %q, want main/empty", specs[0].BranchIdentitySlug, specs[0].BranchSlug)
|
||||
}
|
||||
if specs[1].BranchIdentitySlug != "release-1.2" || specs[1].BranchSlug != "" {
|
||||
t.Fatalf("backend plan = identity %q path %q, want release-1.2/empty", specs[1].BranchIdentitySlug, specs[1].BranchSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistTaskEnvironmentRepos_RefreshesExistingRows(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
repo.taskEnvironmentRepos["env-refresh"] = []*models.TaskEnvironmentRepo{
|
||||
{
|
||||
ID: "env-refresh-repo-main",
|
||||
TaskEnvironmentID: "env-refresh",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "main",
|
||||
WorktreeID: "wt-stale",
|
||||
WorktreePath: "/old/path",
|
||||
WorktreeBranch: "old-branch",
|
||||
Position: 5,
|
||||
ErrorMessage: "old error",
|
||||
},
|
||||
}
|
||||
|
||||
exec.persistTaskEnvironmentRepos(context.Background(), "env-refresh", []RepoWorktreeResult{
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "main",
|
||||
WorktreeID: "wt-repaired",
|
||||
WorktreePath: "/new/path",
|
||||
WorktreeBranch: "new-branch",
|
||||
ErrorMessage: "",
|
||||
},
|
||||
})
|
||||
|
||||
rows := repo.taskEnvironmentRepos["env-refresh"]
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.WorktreeID != "wt-repaired" || row.WorktreePath != "/new/path" || row.WorktreeBranch != "new-branch" || row.Position != 0 || row.ErrorMessage != "" {
|
||||
t.Fatalf("row was not refreshed: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistTaskEnvironmentRepos_MigratesLegacyFlatRowToBranchIdentity(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
repo.taskEnvironmentRepos["env-legacy"] = []*models.TaskEnvironmentRepo{
|
||||
{
|
||||
ID: "env-legacy-repo-flat",
|
||||
TaskEnvironmentID: "env-legacy",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "",
|
||||
WorktreeID: "wt-flat",
|
||||
WorktreePath: "/old/flat",
|
||||
},
|
||||
}
|
||||
|
||||
exec.persistTaskEnvironmentRepos(context.Background(), "env-legacy", []RepoWorktreeResult{
|
||||
{
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "release-1.2",
|
||||
WorktreeID: "wt-flat",
|
||||
WorktreePath: "/new/flat",
|
||||
WorktreeBranch: "feature/task",
|
||||
},
|
||||
})
|
||||
|
||||
rows := repo.taskEnvironmentRepos["env-legacy"]
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.BranchSlug != "release-1.2" || row.WorktreeID != "wt-flat" || row.WorktreePath != "/new/flat" || row.WorktreeBranch != "feature/task" {
|
||||
t.Fatalf("legacy row was not migrated: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockRepositoryTaskEnvironmentRepoUpdatesAreBranchScoped(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{
|
||||
TaskEnvironmentID: "env-branches",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "main",
|
||||
WorktreeID: "wt-main",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskEnvironmentRepo(main): %v", err)
|
||||
}
|
||||
if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{
|
||||
TaskEnvironmentID: "env-branches",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "feature",
|
||||
WorktreeID: "wt-feature",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskEnvironmentRepo(feature): %v", err)
|
||||
}
|
||||
rows := repo.taskEnvironmentRepos["env-branches"]
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %d, want 2", len(rows))
|
||||
}
|
||||
if rows[0].ID == rows[1].ID {
|
||||
t.Fatalf("branch-specific rows should have distinct IDs, both got %q", rows[0].ID)
|
||||
}
|
||||
featureRowID := rows[1].ID
|
||||
|
||||
if err := repo.UpdateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{
|
||||
ID: featureRowID,
|
||||
TaskEnvironmentID: "env-branches",
|
||||
RepositoryID: "repo-kandev",
|
||||
BranchSlug: "feature",
|
||||
WorktreeID: "wt-feature-updated",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateTaskEnvironmentRepo(feature): %v", err)
|
||||
}
|
||||
|
||||
rows = repo.taskEnvironmentRepos["env-branches"]
|
||||
if rows[0].WorktreeID != "wt-main" {
|
||||
t.Fatalf("main WorktreeID = %q, want wt-main", rows[0].WorktreeID)
|
||||
}
|
||||
if rows[1].WorktreeID != "wt-feature-updated" {
|
||||
t.Fatalf("feature WorktreeID = %q, want wt-feature-updated", rows[1].WorktreeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchPreparedSession_SingleRepo_DoesNotPopulateRequestRepositories(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
taskID := "task-single-1"
|
||||
|
||||
@@ -40,6 +40,7 @@ type repoInfo struct {
|
||||
BaseBranch string
|
||||
CheckoutBranch string
|
||||
PRNumber int // GitHub PR number when CheckoutBranch is a PR head; sourced from task_repositories.metadata["pr_number"].
|
||||
Position int
|
||||
WorktreeBranchPrefix string
|
||||
PullBeforeWorktree bool
|
||||
Repository *models.Repository
|
||||
@@ -95,6 +96,7 @@ func (e *Executor) resolveTaskRepoInfo(ctx context.Context, tr *models.TaskRepos
|
||||
BaseBranch: tr.BaseBranch,
|
||||
CheckoutBranch: tr.CheckoutBranch,
|
||||
PRNumber: prNumberFromMetadata(tr.Metadata),
|
||||
Position: tr.Position,
|
||||
}
|
||||
if info.RepositoryID == "" {
|
||||
return info, nil
|
||||
@@ -276,6 +278,7 @@ func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string
|
||||
SessionID: session.ID,
|
||||
WorktreeID: w.WorktreeID,
|
||||
RepositoryID: w.RepositoryID,
|
||||
BranchSlug: w.BranchSlug,
|
||||
Position: i,
|
||||
WorktreePath: w.WorktreePath,
|
||||
WorktreeBranch: w.WorktreeBranch,
|
||||
|
||||
@@ -132,6 +132,7 @@ type repoStore interface {
|
||||
ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error)
|
||||
ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error)
|
||||
CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error
|
||||
ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error)
|
||||
ListSessionsWithBranches(ctx context.Context) ([]models.SessionBranchInfo, error)
|
||||
GetRepository(ctx context.Context, id string) (*models.Repository, error)
|
||||
UpdateRepository(ctx context.Context, repository *models.Repository) error
|
||||
@@ -140,6 +141,7 @@ type repoStore interface {
|
||||
// Multi-repo task environment children
|
||||
CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error
|
||||
ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error)
|
||||
UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error
|
||||
// Session history + plan (for context handover)
|
||||
GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error)
|
||||
}
|
||||
|
||||
@@ -642,6 +642,7 @@ type TaskSessionWorktree struct {
|
||||
SessionID string `json:"session_id"`
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
RepositoryID string `json:"repository_id"`
|
||||
BranchSlug string `json:"branch_slug,omitempty"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -1025,6 +1026,7 @@ type TaskEnvironmentRepo struct {
|
||||
ID string `json:"id"`
|
||||
TaskEnvironmentID string `json:"task_environment_id"`
|
||||
RepositoryID string `json:"repository_id"`
|
||||
BranchSlug string `json:"branch_slug,omitempty"`
|
||||
WorktreeID string `json:"worktree_id,omitempty"`
|
||||
WorktreePath string `json:"worktree_path,omitempty"`
|
||||
WorktreeBranch string `json:"worktree_branch,omitempty"`
|
||||
@@ -1093,6 +1095,9 @@ func (r *TaskEnvironmentRepo) ToAPI() map[string]interface{} {
|
||||
if r.WorktreeID != "" {
|
||||
out["worktree_id"] = r.WorktreeID
|
||||
}
|
||||
if r.BranchSlug != "" {
|
||||
out["branch_slug"] = r.BranchSlug
|
||||
}
|
||||
if r.WorktreePath != "" {
|
||||
out["worktree_path"] = r.WorktreePath
|
||||
}
|
||||
|
||||
@@ -272,6 +272,26 @@ func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEnvironmentRepoToAPIIncludesBranchSlug(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
repo := &TaskEnvironmentRepo{
|
||||
ID: "ter-1",
|
||||
TaskEnvironmentID: "env-1",
|
||||
RepositoryID: "repo-1",
|
||||
BranchSlug: "branch-5hn",
|
||||
WorktreeID: "wt-1",
|
||||
Position: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
api := repo.ToAPI()
|
||||
|
||||
if api["branch_slug"] != "branch-5hn" {
|
||||
t.Fatalf("branch_slug = %v, want branch-5hn", api["branch_slug"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskIsFromOfficeField verifies the IsFromOffice field round-trips
|
||||
// through the model. The actual office-vs-kanban predicate is computed in
|
||||
// SQL by isFromOfficeProjection (see repository/sqlite/task.go) so the
|
||||
|
||||
@@ -57,6 +57,7 @@ func (r *Repository) runMigrations() error {
|
||||
r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`)
|
||||
r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`)
|
||||
r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`)
|
||||
r.migrate.Apply("task_session_worktrees.branch_slug", `ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`)
|
||||
r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`)
|
||||
// Remove FK constraint on workflow_id to allow ephemeral tasks without workflows
|
||||
if err := r.migrateTasksRemoveWorkflowFK(); err != nil {
|
||||
@@ -82,6 +83,9 @@ func (r *Repository) runMigrations() error {
|
||||
if err := r.migrateTaskEnvironmentsRemoveAgentExecutionID(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.migrate.Apply("workflows.sort_order", `ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`)
|
||||
r.migrate.Apply("workflows.agent_profile_id", `ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`)
|
||||
r.migrate.Apply("workflows.hidden", `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`)
|
||||
@@ -394,6 +398,94 @@ func (r *Repository) migrateTaskEnvironmentsRemoveAgentExecutionID() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranch() error {
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
return r.migrateTaskEnvironmentReposAllowMultiBranchPostgres()
|
||||
}
|
||||
return r.recreateTableNamed(
|
||||
"task_environment_repos.recreate_allow_multi_branch",
|
||||
"task_environment_repos",
|
||||
"UNIQUE(task_environment_id, repository_id)",
|
||||
[]string{
|
||||
`CREATE TABLE task_environment_repos_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_environment_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
branch_slug TEXT NOT NULL DEFAULT '',
|
||||
worktree_id TEXT DEFAULT '',
|
||||
worktree_path TEXT DEFAULT '',
|
||||
worktree_branch TEXT DEFAULT '',
|
||||
position INTEGER DEFAULT 0,
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE,
|
||||
UNIQUE(task_environment_id, repository_id, branch_slug)
|
||||
)`,
|
||||
`INSERT INTO task_environment_repos_new SELECT
|
||||
id, task_environment_id, repository_id, '',
|
||||
worktree_id, worktree_path, worktree_branch,
|
||||
position, error_message, created_at, updated_at
|
||||
FROM task_environment_repos`,
|
||||
`DROP TABLE task_environment_repos`,
|
||||
`ALTER TABLE task_environment_repos_new RENAME TO task_environment_repos`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id)`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (r *Repository) migrateTaskEnvironmentReposAllowMultiBranchPostgres() error {
|
||||
if _, err := r.db.Exec(`ALTER TABLE task_environment_repos ADD COLUMN IF NOT EXISTS branch_slug TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return fmt.Errorf("add task_environment_repos.branch_slug: %w", err)
|
||||
}
|
||||
if _, err := r.db.Exec(`
|
||||
DO $$
|
||||
DECLARE
|
||||
old_constraint_name text;
|
||||
BEGIN
|
||||
SELECT con.conname INTO old_constraint_name
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
||||
WHERE rel.relname = 'task_environment_repos'
|
||||
AND nsp.nspname = current_schema()
|
||||
AND con.contype = 'u'
|
||||
AND (
|
||||
SELECT array_agg(attr.attname ORDER BY cols.ordinality)
|
||||
FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality)
|
||||
JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum
|
||||
) = ARRAY['task_environment_id', 'repository_id'];
|
||||
|
||||
IF old_constraint_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE task_environment_repos DROP CONSTRAINT %I', old_constraint_name);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
||||
WHERE rel.relname = 'task_environment_repos'
|
||||
AND nsp.nspname = current_schema()
|
||||
AND con.contype = 'u'
|
||||
AND (
|
||||
SELECT array_agg(attr.attname ORDER BY cols.ordinality)
|
||||
FROM unnest(con.conkey) WITH ORDINALITY AS cols(attnum, ordinality)
|
||||
JOIN pg_attribute attr ON attr.attrelid = con.conrelid AND attr.attnum = cols.attnum
|
||||
) = ARRAY['task_environment_id', 'repository_id', 'branch_slug']
|
||||
) THEN
|
||||
ALTER TABLE task_environment_repos
|
||||
ADD CONSTRAINT task_environment_repos_env_repo_branch_key
|
||||
UNIQUE (task_environment_id, repository_id, branch_slug);
|
||||
END IF;
|
||||
END $$;
|
||||
`); err != nil {
|
||||
return fmt.Errorf("migrate task_environment_repos unique constraint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTaskRepositoriesAllowMultiBranch swaps the legacy
|
||||
// UNIQUE(task_id, repository_id) constraint on task_repositories for
|
||||
// UNIQUE(task_id, repository_id, base_branch, checkout_branch). The wider
|
||||
@@ -851,10 +943,10 @@ func (r *Repository) backfillTaskEnvironmentRepos() error {
|
||||
for _, row := range pending {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO task_environment_repos (
|
||||
id, task_environment_id, repository_id,
|
||||
id, task_environment_id, repository_id, branch_slug,
|
||||
worktree_id, worktree_path, worktree_branch,
|
||||
position, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?)
|
||||
) VALUES (?, ?, ?, '', ?, ?, ?, 0, '', ?, ?)
|
||||
`, uuid.New().String(), row.envID, row.repoID,
|
||||
row.wtID, row.wtPath, row.wtBranch,
|
||||
row.createdAt, row.createdAt); err != nil {
|
||||
|
||||
@@ -578,6 +578,7 @@ const sessionWorktreeSchemaDDL = `
|
||||
id TEXT PRIMARY KEY,
|
||||
task_environment_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
branch_slug TEXT NOT NULL DEFAULT '',
|
||||
worktree_id TEXT DEFAULT '',
|
||||
worktree_path TEXT DEFAULT '',
|
||||
worktree_branch TEXT DEFAULT '',
|
||||
@@ -586,7 +587,7 @@ const sessionWorktreeSchemaDDL = `
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE,
|
||||
UNIQUE(task_environment_id, repository_id)
|
||||
UNIQUE(task_environment_id, repository_id, branch_slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id);
|
||||
@@ -597,6 +598,7 @@ const sessionWorktreeSchemaDDL = `
|
||||
session_id TEXT NOT NULL,
|
||||
worktree_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
branch_slug TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER DEFAULT 0,
|
||||
worktree_path TEXT DEFAULT '',
|
||||
worktree_branch TEXT DEFAULT '',
|
||||
|
||||
@@ -374,6 +374,50 @@ func TestCreateTaskEnvironment_AllowsNonWorktreeWithEmptyWorkspace(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskEnvironment_AllowsSameRepoMultiBranchRows(t *testing.T) {
|
||||
repo := newRepoForHealTests(t)
|
||||
insertTask(t, repo.db, "task-H2")
|
||||
|
||||
err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{
|
||||
ID: "env-H2",
|
||||
TaskID: "task-H2",
|
||||
ExecutorType: string(models.ExecutorTypeWorktree),
|
||||
WorktreePath: "/workspace/main",
|
||||
WorkspacePath: "/workspace",
|
||||
Repos: []*models.TaskEnvironmentRepo{
|
||||
{
|
||||
RepositoryID: "repo-shared",
|
||||
WorktreeID: "wt-main",
|
||||
WorktreePath: "/workspace/repo",
|
||||
WorktreeBranch: "feature/main",
|
||||
Position: 0,
|
||||
},
|
||||
{
|
||||
RepositoryID: "repo-shared",
|
||||
BranchSlug: "branch-5hn",
|
||||
WorktreeID: "wt-branch",
|
||||
WorktreePath: "/workspace/repo/branch-5hn",
|
||||
WorktreeBranch: "feature/branch",
|
||||
Position: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
rows, err := repo.ListTaskEnvironmentRepos(context.Background(), "env-H2")
|
||||
if err != nil {
|
||||
t.Fatalf("list repos: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 environment repo rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0].BranchSlug != "" || rows[1].BranchSlug != "branch-5hn" {
|
||||
t.Fatalf("unexpected branch slugs: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree — symmetric
|
||||
// guard: a writer must not be able to clear a previously-populated
|
||||
// workspace_path on a worktree env.
|
||||
|
||||
@@ -53,3 +53,52 @@ func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) {
|
||||
t.Fatalf("task environment count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTaskEnvironmentReposMultiBranchMigration(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE task_environment_repos (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_environment_id TEXT NOT NULL,
|
||||
repository_id TEXT NOT NULL,
|
||||
worktree_id TEXT DEFAULT '',
|
||||
worktree_path TEXT DEFAULT '',
|
||||
worktree_branch TEXT DEFAULT '',
|
||||
position INTEGER DEFAULT 0,
|
||||
error_message TEXT DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(task_environment_id, repository_id)
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy task_environment_repos: %v", err)
|
||||
}
|
||||
|
||||
repo := &Repository{db: db}
|
||||
if err := repo.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil {
|
||||
t.Fatalf("migrate task_environment_repos: %v", err)
|
||||
}
|
||||
if err := repo.migrateTaskEnvironmentReposAllowMultiBranch(); err != nil {
|
||||
t.Fatalf("rerun migration: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO task_environment_repos (
|
||||
id, task_environment_id, repository_id, branch_slug,
|
||||
worktree_id, created_at, updated_at
|
||||
) VALUES
|
||||
('ter-main', 'env-1', 'repo-1', '', 'wt-main', $1, $1),
|
||||
('ter-branch', 'env-1', 'repo-1', 'branch-5hn', 'wt-branch', $1, $1)
|
||||
`, now); err != nil {
|
||||
t.Fatalf("insert same repo multi-branch rows: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO task_environment_repos (
|
||||
id, task_environment_id, repository_id, branch_slug,
|
||||
worktree_id, created_at, updated_at
|
||||
) VALUES ('ter-dupe', 'env-1', 'repo-1', '', 'wt-dupe', $1, $1)
|
||||
`, now); err == nil {
|
||||
t.Fatal("expected duplicate env/repo/branch insert to fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1071,11 +1071,15 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO task_session_worktrees (
|
||||
id, session_id, worktree_id, repository_id, position,
|
||||
id, session_id, worktree_id, repository_id, branch_slug, position,
|
||||
worktree_path, worktree_branch, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id, worktree_id) DO UPDATE SET
|
||||
repository_id = excluded.repository_id,
|
||||
branch_slug = CASE
|
||||
WHEN excluded.branch_slug != '' THEN excluded.branch_slug
|
||||
ELSE task_session_worktrees.branch_slug
|
||||
END,
|
||||
position = excluded.position,
|
||||
worktree_path = excluded.worktree_path,
|
||||
worktree_branch = excluded.worktree_branch,
|
||||
@@ -1085,6 +1089,7 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt
|
||||
sessionWorktree.SessionID,
|
||||
sessionWorktree.WorktreeID,
|
||||
sessionWorktree.RepositoryID,
|
||||
sessionWorktree.BranchSlug,
|
||||
sessionWorktree.Position,
|
||||
sessionWorktree.WorktreePath,
|
||||
sessionWorktree.WorktreeBranch,
|
||||
@@ -1110,10 +1115,13 @@ func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessio
|
||||
func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
|
||||
SELECT
|
||||
tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position,
|
||||
tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id,
|
||||
COALESCE(tsw.branch_slug, ''), tsw.position,
|
||||
tsw.worktree_path, tsw.worktree_branch, tsw.created_at
|
||||
FROM task_session_worktrees tsw
|
||||
WHERE tsw.session_id = ?
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.status = 'active'
|
||||
ORDER BY tsw.position ASC, tsw.created_at ASC
|
||||
`), sessionID)
|
||||
if err != nil {
|
||||
@@ -1129,6 +1137,7 @@ func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID str
|
||||
&wt.SessionID,
|
||||
&wt.WorktreeID,
|
||||
&wt.RepositoryID,
|
||||
&wt.BranchSlug,
|
||||
&wt.Position,
|
||||
&wt.WorktreePath,
|
||||
&wt.WorktreeBranch,
|
||||
@@ -1173,9 +1182,11 @@ func (r *Repository) appendWorktreesForSessionChunk(
|
||||
) error {
|
||||
placeholders, args := buildInPlaceholders(sessionIDs)
|
||||
query := `SELECT tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position,
|
||||
tsw.worktree_path, tsw.worktree_branch, tsw.created_at
|
||||
COALESCE(tsw.branch_slug, ''), tsw.worktree_path, tsw.worktree_branch, tsw.created_at
|
||||
FROM task_session_worktrees tsw
|
||||
WHERE tsw.session_id IN (` + placeholders + `)
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.status = 'active'
|
||||
ORDER BY tsw.position ASC, tsw.created_at ASC`
|
||||
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
|
||||
@@ -1187,7 +1198,7 @@ func (r *Repository) appendWorktreesForSessionChunk(
|
||||
for rows.Next() {
|
||||
var wt models.TaskSessionWorktree
|
||||
if err := rows.Scan(&wt.ID, &wt.SessionID, &wt.WorktreeID, &wt.RepositoryID,
|
||||
&wt.Position, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil {
|
||||
&wt.Position, &wt.BranchSlug, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result[wt.SessionID] = append(result[wt.SessionID], &wt)
|
||||
|
||||
@@ -111,6 +111,71 @@ func TestTaskSessionNotFoundErrorsAreTyped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTaskSessionWorktreesFiltersInactiveRows(t *testing.T) {
|
||||
repo := newRepoForSessionTests(t)
|
||||
ctx := context.Background()
|
||||
seedForMsgTest(t, repo, "task-worktrees", "session-worktrees", "turn-worktrees")
|
||||
worktrees := []*models.TaskSessionWorktree{
|
||||
{
|
||||
ID: "wt-active",
|
||||
SessionID: "session-worktrees",
|
||||
WorktreeID: "worktree-active",
|
||||
RepositoryID: "repo-1",
|
||||
BranchSlug: "main",
|
||||
},
|
||||
{
|
||||
ID: "wt-status-deleted",
|
||||
SessionID: "session-worktrees",
|
||||
WorktreeID: "worktree-status-deleted",
|
||||
RepositoryID: "repo-1",
|
||||
BranchSlug: "deleted-status",
|
||||
},
|
||||
{
|
||||
ID: "wt-timestamp-deleted",
|
||||
SessionID: "session-worktrees",
|
||||
WorktreeID: "worktree-timestamp-deleted",
|
||||
RepositoryID: "repo-1",
|
||||
BranchSlug: "deleted-at",
|
||||
},
|
||||
}
|
||||
for _, wt := range worktrees {
|
||||
if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil {
|
||||
t.Fatalf("CreateTaskSessionWorktree(%s): %v", wt.ID, err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := repo.db.Exec(repo.db.Rebind(`
|
||||
UPDATE task_session_worktrees
|
||||
SET status = 'deleted', updated_at = ?
|
||||
WHERE id = ?
|
||||
`), now, "wt-status-deleted"); err != nil {
|
||||
t.Fatalf("mark status deleted: %v", err)
|
||||
}
|
||||
if _, err := repo.db.Exec(repo.db.Rebind(`
|
||||
UPDATE task_session_worktrees
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`), now, now, "wt-timestamp-deleted"); err != nil {
|
||||
t.Fatalf("mark timestamp deleted: %v", err)
|
||||
}
|
||||
|
||||
listed, err := repo.ListTaskSessionWorktrees(ctx, "session-worktrees")
|
||||
if err != nil {
|
||||
t.Fatalf("ListTaskSessionWorktrees: %v", err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].ID != "wt-active" {
|
||||
t.Fatalf("ListTaskSessionWorktrees = %+v, want only wt-active", listed)
|
||||
}
|
||||
batched, err := repo.ListWorktreesBySessionIDs(ctx, []string{"session-worktrees"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorktreesBySessionIDs: %v", err)
|
||||
}
|
||||
rows := batched["session-worktrees"]
|
||||
if len(rows) != 1 || rows[0].ID != "wt-active" {
|
||||
t.Fatalf("ListWorktreesBySessionIDs = %+v, want only wt-active", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLastAgentMessage_NoMessages verifies that a session with no messages
|
||||
// returns an empty string and sql.ErrNoRows.
|
||||
func TestGetLastAgentMessage_NoMessages(t *testing.T) {
|
||||
|
||||
@@ -211,12 +211,12 @@ func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO task_environment_repos (
|
||||
id, task_environment_id, repository_id,
|
||||
id, task_environment_id, repository_id, branch_slug,
|
||||
worktree_id, worktree_path, worktree_branch,
|
||||
position, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
repo.ID, repo.TaskEnvironmentID, repo.RepositoryID,
|
||||
repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug,
|
||||
repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch,
|
||||
repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt,
|
||||
)
|
||||
@@ -237,12 +237,12 @@ func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx
|
||||
|
||||
_, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO task_environment_repos (
|
||||
id, task_environment_id, repository_id,
|
||||
id, task_environment_id, repository_id, branch_slug,
|
||||
worktree_id, worktree_path, worktree_branch,
|
||||
position, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
repo.ID, repo.TaskEnvironmentID, repo.RepositoryID,
|
||||
repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug,
|
||||
repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch,
|
||||
repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt,
|
||||
)
|
||||
@@ -253,6 +253,7 @@ func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx
|
||||
func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) {
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(`
|
||||
SELECT id, task_environment_id, repository_id,
|
||||
COALESCE(branch_slug, ''),
|
||||
worktree_id, worktree_path, worktree_branch,
|
||||
position, error_message, created_at, updated_at
|
||||
FROM task_environment_repos
|
||||
@@ -269,6 +270,7 @@ func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string)
|
||||
repo := &models.TaskEnvironmentRepo{}
|
||||
if err := rows.Scan(
|
||||
&repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID,
|
||||
&repo.BranchSlug,
|
||||
&repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch,
|
||||
&repo.Position, &repo.ErrorMessage, &repo.CreatedAt, &repo.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -285,11 +287,12 @@ func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models
|
||||
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE task_environment_repos SET
|
||||
branch_slug = ?,
|
||||
worktree_id = ?, worktree_path = ?, worktree_branch = ?,
|
||||
position = ?, error_message = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`),
|
||||
repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch,
|
||||
repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch,
|
||||
repo.Position, repo.ErrorMessage, repo.UpdatedAt,
|
||||
repo.ID,
|
||||
)
|
||||
|
||||
@@ -25,13 +25,15 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reject an invalid explicit slug up-front. If req.BranchSlug is non-empty
|
||||
// but normalizes to "", the reuse lookup downstream would silently match
|
||||
// the bare-slug worktree (or none) and createInTaskDir would later fail
|
||||
// with a less obvious error.
|
||||
// Reject invalid explicit slugs up-front. If either slug is non-empty but
|
||||
// normalizes to "", reuse/path lookup would silently target the wrong
|
||||
// worktree or fail later with a less obvious error.
|
||||
if req.BranchSlug != "" && SanitizeBranchSlug(req.BranchSlug) == "" {
|
||||
return nil, ErrInvalidBranchSlug
|
||||
}
|
||||
if req.BranchIdentitySlug != "" && SanitizeBranchSlug(req.BranchIdentitySlug) == "" {
|
||||
return nil, ErrInvalidBranchSlug
|
||||
}
|
||||
|
||||
if wt, handled, err := m.tryReuseExisting(ctx, req); handled {
|
||||
return wt, err
|
||||
@@ -83,7 +85,7 @@ func (m *Manager) tryReuseExisting(ctx context.Context, req CreateRequest) (*Wor
|
||||
// lookup by RepositoryID AND BranchSlug — otherwise the second branch's
|
||||
// Create would return the first branch's worktree, silently collapsing
|
||||
// two distinct worktrees into one on-disk directory.
|
||||
reuseSlug := SanitizeBranchSlug(req.BranchSlug)
|
||||
reuseSlug := requestBranchIdentitySlug(req)
|
||||
if req.SessionID != "" {
|
||||
existing, err := m.GetBySessionAndRepo(ctx, req.SessionID, req.RepositoryID, reuseSlug)
|
||||
if err == nil && existing != nil {
|
||||
@@ -135,6 +137,13 @@ func (m *Manager) tryReuseExisting(ctx context.Context, req CreateRequest) (*Wor
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func requestBranchIdentitySlug(req CreateRequest) string {
|
||||
if req.BranchIdentitySlug != "" {
|
||||
return SanitizeBranchSlug(req.BranchIdentitySlug)
|
||||
}
|
||||
return SanitizeBranchSlug(req.BranchSlug)
|
||||
}
|
||||
|
||||
// resolveBaseRefWithFallback resolves the base ref for a new worktree, optionally
|
||||
// pulling from origin first, and falling back to req.FallbackBaseBranch when the
|
||||
// requested base branch is missing. When the fallback path is taken, req.BaseBranch
|
||||
|
||||
@@ -19,7 +19,7 @@ func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, work
|
||||
SessionID: req.SessionID,
|
||||
TaskID: req.TaskID,
|
||||
RepositoryID: req.RepositoryID,
|
||||
BranchSlug: SanitizeBranchSlug(req.BranchSlug),
|
||||
BranchSlug: requestBranchIdentitySlug(req),
|
||||
RepositoryPath: req.RepositoryPath,
|
||||
Path: worktreePath,
|
||||
Branch: branchName,
|
||||
|
||||
@@ -185,6 +185,11 @@ type CreateRequest struct {
|
||||
// derive a deterministic, filesystem-safe slug (see SanitizeBranchSlug).
|
||||
BranchSlug string
|
||||
|
||||
// BranchIdentitySlug, when non-empty, is the stable branch key used for
|
||||
// reuse lookup, cache keys, and persisted task_session_worktrees rows. It
|
||||
// may differ from BranchSlug when the primary branch keeps the flat path.
|
||||
BranchIdentitySlug string
|
||||
|
||||
// OnSyncProgress receives progress updates for pre-worktree branch sync.
|
||||
OnSyncProgress SyncProgressCallback
|
||||
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
function listTaskWorktreeRoots(tmpDir: string): string[] {
|
||||
const tasksRoot = path.join(tmpDir, ".kandev", "tasks");
|
||||
if (!fs.existsSync(tasksRoot)) return [];
|
||||
return fs
|
||||
.readdirSync(tasksRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(tasksRoot, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function waitForDoneSessionCount(
|
||||
apiClient: import("../../helpers/api-client").ApiClient,
|
||||
taskId: string,
|
||||
count: number,
|
||||
message: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
return sessions.filter((s) => DONE_STATES.includes(s.state)).length;
|
||||
},
|
||||
{ timeout: 45_000, message },
|
||||
)
|
||||
.toBe(count);
|
||||
}
|
||||
|
||||
async function waitForTaskEnvironmentId(
|
||||
apiClient: import("../../helpers/api-client").ApiClient,
|
||||
taskId: string,
|
||||
message: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const env = await apiClient.getTaskEnvironment(taskId);
|
||||
return env?.id ?? "";
|
||||
},
|
||||
{ timeout: 45_000, message },
|
||||
)
|
||||
.not.toBe("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests for launching multiple sessions on the same task.
|
||||
* Verifies session handover context injection and environment reuse.
|
||||
@@ -140,4 +186,86 @@ test.describe("Multi-session", () => {
|
||||
// finds the existing environment and reuses its WorktreeID.
|
||||
expect(firstSession.task_environment_id).toBe(env!.id);
|
||||
});
|
||||
|
||||
test("same-repo multi-branch second session reuses the existing task workspace", async ({
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const rootsBefore = listTaskWorktreeRoots(backend.tmpDir);
|
||||
const task = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Multi-Branch Workspace Reuse Task",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repositories: [
|
||||
{ repository_id: seedData.repositoryId, base_branch: "main" },
|
||||
{
|
||||
repository_id: seedData.repositoryId,
|
||||
base_branch: "main",
|
||||
checkout_branch: "branch-5hn",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.launchSession(
|
||||
{
|
||||
task_id: task.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "/e2e:simple-message",
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
await waitForDoneSessionCount(
|
||||
apiClient,
|
||||
task.id,
|
||||
1,
|
||||
"Waiting for first multi-branch session to finish",
|
||||
);
|
||||
|
||||
await waitForTaskEnvironmentId(apiClient, task.id, "Waiting for first task environment");
|
||||
const envBefore = await apiClient.getTaskEnvironment(task.id);
|
||||
expect(envBefore?.id, "first session must create a task environment").toBeTruthy();
|
||||
const rootsAfterFirst = listTaskWorktreeRoots(backend.tmpDir);
|
||||
const createdRoots = rootsAfterFirst.filter((root) => !rootsBefore.includes(root));
|
||||
expect(
|
||||
createdRoots,
|
||||
"first session should create exactly one task workspace root",
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
fs.readdirSync(createdRoots[0]).filter((name) => name.includes("branch-5hn")).length,
|
||||
).toBe(1);
|
||||
const rootEntriesAfterFirst = fs.readdirSync(createdRoots[0]).sort();
|
||||
|
||||
const launched = await apiClient.launchSession(
|
||||
{
|
||||
task_id: task.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "/e2e:simple-message",
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
await waitForDoneSessionCount(
|
||||
apiClient,
|
||||
task.id,
|
||||
2,
|
||||
"Waiting for second multi-branch session to finish",
|
||||
);
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const launchedSession = sessions.find((s) => s.id === launched.session_id);
|
||||
expect(launchedSession?.task_environment_id).toBe(envBefore!.id);
|
||||
expect(listTaskWorktreeRoots(backend.tmpDir)).toEqual(rootsAfterFirst);
|
||||
expect(fs.readdirSync(createdRoots[0]).sort()).toEqual(rootEntriesAfterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
|
||||
- Single-branch tasks: `~/.kandev/tasks/<task-dir>/<repo>/` (unchanged).
|
||||
- Multi-branch tasks: first occurrence of each repo keeps `~/.kandev/tasks/<task-dir>/<repo>/`; additional occurrences sit as siblings at `~/.kandev/tasks/<task-dir>/<repo>-<branch-slug>/`. The slug is derived deterministically from `CheckoutBranch` (or `BaseBranch` when the checkout branch is empty) via `worktree.SanitizeBranchSlug`.
|
||||
- The orchestrator (`buildRepoSpecs`) detects same-repo duplicates and tags additional rows with a `BranchSlug`; the worktree manager applies the slug as a suffix at the task-root level. Sibling siting (rather than nesting inside the primary) keeps each worktree's git scope isolated.
|
||||
- Subsequent sessions on the same task, including handoffs and additional agents, reuse the task's existing worktree IDs for every `(repository, branch)` pair. They do not create a new task directory or sibling worktree set unless the task itself gains a new branch/repository pair.
|
||||
|
||||
### PRs
|
||||
|
||||
@@ -70,6 +71,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
|
||||
- `TestCreateTask_RejectsSameRepoSameBranch` — dedup guard still rejects exact duplicates.
|
||||
- `TestAddBranchToTask_HappyPath` — second branch appended after the fact lands as a new row.
|
||||
- `TestAddBranchToTask_RejectsDuplicate` — re-adding the same `(repo, branch)` errors.
|
||||
- `TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug` — a follow-on session for the same task reuses each existing branch worktree instead of preparing a new task directory.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user