feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest

Phase 1.1 of multi-repository task support: extends the lifecycle types
with explicit per-repo lists, while keeping the legacy single-repo
top-level fields as the synthesized fallback. No caller behavior changes
yet — preparers still see length-1 specs for single-repo launches.

- LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper.
- EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper.
- EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes.
- runEnvironmentPreparer forwards req.Repositories into the prepare
  request, inheriting the request-level RepoSetupScript when each
  per-repo entry has none.
- Tests cover explicit-list precedence, synthesized fallback, and
  repo-less requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-04-25 23:17:59 +01:00
parent 8bccefdc18
commit a9736c1998
4 changed files with 229 additions and 1 deletions
@@ -20,6 +20,23 @@ const (
PrepareStepSkipped PrepareStepStatus = "skipped"
)
// RepoPrepareSpec describes one repository for multi-repo environment preparation.
// Mirrors the per-repo prepare fields that EnvPrepareRequest historically
// carried at the top level. When EnvPrepareRequest.Repositories is non-empty,
// each entry produces one prepared worktree under the shared TaskDirName.
type RepoPrepareSpec struct {
RepositoryID string
RepositoryPath string
RepoName string
BaseBranch string
CheckoutBranch string
WorktreeID string
WorktreeBranch string
WorktreeBranchPrefix string
PullBeforeWorktree bool
RepoSetupScript string
}
// EnvPrepareRequest contains the parameters for environment preparation.
type EnvPrepareRequest struct {
TaskID string
@@ -44,9 +61,40 @@ 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
// Repositories carries one entry per repository when the request is
// multi-repo. When non-empty it is the source of truth; the legacy
// single-repo top-level fields above are populated from Repositories[0]
// for callers that have not yet been updated.
Repositories []RepoPrepareSpec
Env map[string]string
}
// RepoSpecs returns the per-repo prepare specs for this request. When
// Repositories is set it is returned verbatim; otherwise a length-1 list is
// synthesized from the legacy top-level single-repo fields. Returns an empty
// slice for repo-less requests.
func (r *EnvPrepareRequest) RepoSpecs() []RepoPrepareSpec {
if len(r.Repositories) > 0 {
return r.Repositories
}
if r.RepositoryID == "" && r.RepositoryPath == "" {
return nil
}
return []RepoPrepareSpec{{
RepositoryID: r.RepositoryID,
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranch: r.WorktreeBranch,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
RepoSetupScript: r.RepoSetupScript,
}}
}
// PrepareStep represents a single step in the preparation process.
type PrepareStep struct {
Name string `json:"name"`
@@ -60,6 +108,18 @@ type PrepareStep struct {
EndedAt *time.Time `json:"ended_at,omitempty"`
}
// RepoWorktreeResult is the per-repository outcome of environment preparation.
// Populated by preparers that handle multi-repo launches; each entry corresponds
// to one RepoPrepareSpec from the request.
type RepoWorktreeResult struct {
RepositoryID string `json:"repository_id"`
WorktreeID string `json:"worktree_id,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
WorktreePath string `json:"worktree_path,omitempty"`
MainRepoGitDir string `json:"main_repo_git_dir,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// EnvPrepareResult contains the result of environment preparation.
type EnvPrepareResult struct {
Success bool `json:"success"`
@@ -68,10 +128,15 @@ type EnvPrepareResult struct {
ErrorMessage string `json:"error_message,omitempty"`
Duration time.Duration `json:"duration"`
// Worktree fields (populated when worktree preparer runs)
// Worktree fields (populated when worktree preparer runs).
// Legacy single-worktree fields; for multi-repo results they mirror Worktrees[0].
WorktreeID string `json:"worktree_id,omitempty"`
WorktreeBranch string `json:"worktree_branch,omitempty"`
MainRepoGitDir string `json:"main_repo_git_dir,omitempty"`
// Worktrees is the per-repository outcome list when the preparer ran in
// multi-repo mode. Empty for single-repo or repo-less results.
Worktrees []RepoWorktreeResult `json:"worktrees,omitempty"`
}
// PrepareProgressCallback is called when a preparation step changes status.
@@ -381,6 +381,30 @@ func (m *Manager) runEnvironmentPreparer(
RepoName: req.RepoName,
Env: req.Env,
}
// Multi-repo: forward the repo list when the launch request carries one.
// Each per-repo entry inherits the request-level RepoSetupScript when its
// own is empty so single-repo callers continue to work unchanged.
if len(req.Repositories) > 0 {
specs := make([]RepoPrepareSpec, 0, len(req.Repositories))
for _, r := range req.Repositories {
setup := r.RepoSetupScript
if setup == "" {
setup = repoSetupScript
}
specs = append(specs, RepoPrepareSpec{
RepositoryID: r.RepositoryID,
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
RepoSetupScript: setup,
})
}
prepReq.Repositories = specs
}
result, err := preparer.Prepare(ctx, prepReq, m.newProgressCallback(req.TaskID, req.SessionID))
if err != nil {
@@ -248,6 +248,24 @@ func (ae *AgentExecution) EndSessionSpan() {
}
}
// RepoLaunchSpec describes one repository for a multi-repo task launch.
// Mirrors the per-repo launch fields that LaunchRequest historically carried at
// the top level. When LaunchRequest.Repositories is set, each entry produces
// one prepared worktree under the shared TaskDirName.
type RepoLaunchSpec struct {
RepositoryID string
RepositoryPath string
RepositoryURL string // Clone URL for remote executors that need to clone
RepoName string // Repository name used as subdirectory inside TaskDirName
BaseBranch string
CheckoutBranch string
WorktreeID string // Existing worktree ID to reuse (skip creation if set)
WorktreeBranchPrefix string
PullBeforeWorktree bool
RepoSetupScript string // Repository-level setup script (optional)
RepoCleanupScript string // Repository-level cleanup script (optional)
}
// LaunchRequest contains parameters for launching an agent
type LaunchRequest struct {
TaskID string
@@ -284,6 +302,35 @@ 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
// 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
// fields above are populated from Repositories[0] for callers that have not
// yet been updated.
Repositories []RepoLaunchSpec
}
// RepoSpecs returns the per-repo launch specs for this request. When
// Repositories is set it is returned verbatim; otherwise a length-1 list is
// synthesized from the legacy top-level single-repo fields. Returns an empty
// slice for repo-less launches (e.g. quick chat).
func (r *LaunchRequest) RepoSpecs() []RepoLaunchSpec {
if len(r.Repositories) > 0 {
return r.Repositories
}
if r.RepositoryID == "" && r.RepositoryPath == "" {
return nil
}
return []RepoLaunchSpec{{
RepositoryID: r.RepositoryID,
RepositoryPath: r.RepositoryPath,
RepoName: r.RepoName,
BaseBranch: r.BaseBranch,
CheckoutBranch: r.CheckoutBranch,
WorktreeID: r.WorktreeID,
WorktreeBranchPrefix: r.WorktreeBranchPrefix,
PullBeforeWorktree: r.PullBeforeWorktree,
}}
}
// MessageAttachment represents an image or file attachment for agent prompts.
@@ -0,0 +1,92 @@
package lifecycle
import "testing"
func TestLaunchRequest_RepoSpecs_ReturnsExplicitListWhenSet(t *testing.T) {
req := &LaunchRequest{
// Single-repo top-level fields are populated from Repositories[0] for
// backwards compat by callers, but RepoSpecs() must always prefer the
// explicit list when present.
RepositoryID: "legacy-repo",
RepositoryPath: "/legacy",
Repositories: []RepoLaunchSpec{
{RepositoryID: "repo-a", RepositoryPath: "/a", BaseBranch: "main"},
{RepositoryID: "repo-b", RepositoryPath: "/b", BaseBranch: "develop"},
},
}
specs := req.RepoSpecs()
if len(specs) != 2 {
t.Fatalf("expected 2 specs, got %d", len(specs))
}
if specs[0].RepositoryID != "repo-a" || specs[1].RepositoryID != "repo-b" {
t.Errorf("unexpected order: %+v", specs)
}
}
func TestLaunchRequest_RepoSpecs_SynthesizesFromLegacyFields(t *testing.T) {
req := &LaunchRequest{
RepositoryID: "repo-x",
RepositoryPath: "/x",
BaseBranch: "main",
CheckoutBranch: "feature/y",
WorktreeID: "wt-1",
WorktreeBranchPrefix: "feat/",
PullBeforeWorktree: true,
RepoName: "x",
}
specs := req.RepoSpecs()
if len(specs) != 1 {
t.Fatalf("expected length-1 list synthesized, got %d", len(specs))
}
got := specs[0]
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" {
t.Errorf("synthesized spec mismatch: %+v", got)
}
}
func TestLaunchRequest_RepoSpecs_NilForRepoLessLaunch(t *testing.T) {
req := &LaunchRequest{TaskID: "t1", SessionID: "s1"}
if specs := req.RepoSpecs(); specs != nil {
t.Errorf("expected nil for repo-less launch, got %v", specs)
}
}
func TestEnvPrepareRequest_RepoSpecs_ExplicitWins(t *testing.T) {
req := &EnvPrepareRequest{
RepositoryID: "legacy",
RepositoryPath: "/legacy",
Repositories: []RepoPrepareSpec{
{RepositoryID: "a", RepositoryPath: "/a"},
},
}
specs := req.RepoSpecs()
if len(specs) != 1 || specs[0].RepositoryID != "a" {
t.Fatalf("expected explicit list to win; got %+v", specs)
}
}
func TestEnvPrepareRequest_RepoSpecs_SynthesizedCarriesRepoSetupScript(t *testing.T) {
req := &EnvPrepareRequest{
RepositoryID: "r1",
RepositoryPath: "/r1",
BaseBranch: "main",
RepoSetupScript: "make install",
}
specs := req.RepoSpecs()
if len(specs) != 1 {
t.Fatalf("expected 1 spec, got %d", len(specs))
}
if specs[0].RepoSetupScript != "make install" {
t.Errorf("repo setup script not propagated: %+v", specs[0])
}
}
func TestEnvPrepareRequest_RepoSpecs_NilForRepoLess(t *testing.T) {
req := &EnvPrepareRequest{}
if specs := req.RepoSpecs(); specs != nil {
t.Errorf("expected nil, got %+v", specs)
}
}