diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 713d84f78f..ecd1d5cb34 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -28,7 +28,7 @@ repos:
- id: go-lint
name: Go Lint (backend)
- entry: bash -c 'cd apps/backend && golangci-lint run'
+ entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; cd apps/backend && golangci-lint run --new-from-rev=HEAD'
language: system
files: \.go$
pass_filenames: false
diff --git a/apps/.prettierignore b/apps/.prettierignore
index 69e7bafa8f..f64b7abdd6 100644
--- a/apps/.prettierignore
+++ b/apps/.prettierignore
@@ -3,3 +3,5 @@ node_modules
dist
build
coverage
+test-results
+playwright-report
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer.go b/apps/backend/internal/agent/lifecycle/env_preparer.go
index 6a14460422..c64cdb5596 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer.go
@@ -44,12 +44,14 @@ type EnvPrepareRequest struct {
// PrepareStep represents a single step in the preparation process.
type PrepareStep struct {
- Name string `json:"name"`
- Status PrepareStepStatus `json:"status"`
- Output string `json:"output,omitempty"`
- Error string `json:"error,omitempty"`
- StartedAt *time.Time `json:"started_at,omitempty"`
- EndedAt *time.Time `json:"ended_at,omitempty"`
+ Name string `json:"name"`
+ Status PrepareStepStatus `json:"status"`
+ Output string `json:"output,omitempty"`
+ Error string `json:"error,omitempty"`
+ Warning string `json:"warning,omitempty"`
+ WarningDetail string `json:"warning_detail,omitempty"`
+ StartedAt *time.Time `json:"started_at,omitempty"`
+ EndedAt *time.Time `json:"ended_at,omitempty"`
}
// EnvPrepareResult contains the result of environment preparation.
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_local.go b/apps/backend/internal/agent/lifecycle/env_preparer_local.go
index adb8eef053..ad1db7ce23 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer_local.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_local.go
@@ -11,10 +11,11 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/worktree"
)
// LocalPreparer prepares a local (non-worktree) execution environment.
-// Steps: validate workspace → run setup script (if any).
+// Steps: validate workspace → checkout PR branch (if set) → run setup script (if any).
type LocalPreparer struct {
logger *logger.Logger
}
@@ -39,13 +40,18 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
resolvedScript := resolvePreparerSetupScript(req, workspacePath)
totalSteps := 1 // validate workspace
+ if req.CheckoutBranch != "" {
+ totalSteps++
+ }
if resolvedScript != "" {
totalSteps++
}
+ stepIdx := 0
+
// Step 1: Validate workspace path
step := beginStep("Validate workspace")
- reportProgress(onProgress, step, 0, totalSteps)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
if req.WorkspacePath == "" && req.RepositoryPath == "" {
completeStepError(&step, "no workspace or repository path provided")
steps = append(steps, step)
@@ -53,25 +59,45 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
}
completeStepSuccess(&step)
steps = append(steps, step)
- reportProgress(onProgress, step, 0, totalSteps)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ stepIdx++
- // Step 2: Run setup script (if provided)
+ // Step 2: Checkout PR branch (if specified)
+ if req.CheckoutBranch != "" {
+ step = beginStep("Checkout branch")
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ output, err := checkoutBranch(ctx, workspacePath, req.CheckoutBranch)
+ if err != nil {
+ errMsg := fmt.Sprintf("failed to checkout branch %q: %s", req.CheckoutBranch, output)
+ completeStepError(&step, errMsg)
+ steps = append(steps, step)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: errMsg, Duration: time.Since(start)}, fmt.Errorf("checkout branch: %w", err)
+ }
+ step.Output = output
+ completeStepSuccess(&step)
+ steps = append(steps, step)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
+ stepIdx++
+ }
+
+ // Step 3: Run setup script (if provided)
if resolvedScript != "" {
step = beginStep("Run setup script")
- reportProgress(onProgress, step, 1, totalSteps)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
output, err := runSetupScript(ctx, resolvedScript, workspacePath, req.Env)
if err != nil {
completeStepError(&step, err.Error())
step.Output = output
steps = append(steps, step)
- reportProgress(onProgress, step, 1, totalSteps)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
p.logger.Warn("setup script failed", zap.String("task_id", req.TaskID), zap.Error(err))
// Setup script failure is non-fatal — log and continue
} else {
step.Output = output
completeStepSuccess(&step)
steps = append(steps, step)
- reportProgress(onProgress, step, 1, totalSteps)
+ reportProgress(onProgress, step, stepIdx, totalSteps)
}
}
@@ -83,6 +109,28 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
}, nil
}
+// checkoutBranch ensures a branch is checked out in the given working directory.
+// It first tries to fetch the latest from origin, then checks out the branch.
+// If fetch fails (no remote, offline), it falls back to the local branch.
+func checkoutBranch(ctx context.Context, workDir, branch string) (string, error) {
+ // Try to fetch the latest from origin (best-effort).
+ fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", branch)
+ fetchCmd.Dir = workDir
+ fetchCmd.Run() //nolint:errcheck // fetch failure is non-fatal, we fall back to local
+
+ // Checkout the branch. If the local branch doesn't exist but the remote
+ // tracking branch does (from the fetch above), git will create a local
+ // branch tracking the remote automatically.
+ cmd := exec.CommandContext(ctx, "git", "checkout", branch)
+ cmd.Dir = workDir
+ out, err := cmd.CombinedOutput()
+ outStr := strings.TrimSpace(string(out))
+ if err != nil {
+ return outStr, worktree.ClassifyGitError(outStr, err)
+ }
+ return outStr, nil
+}
+
// runSetupScript executes a setup script in the given working directory.
func runSetupScript(ctx context.Context, script, workDir string, env map[string]string) (string, error) {
cmd := exec.CommandContext(ctx, "sh", "-c", script)
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go b/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go
new file mode 100644
index 0000000000..312a4bf118
--- /dev/null
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_local_test.go
@@ -0,0 +1,343 @@
+package lifecycle
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+func newTestLocalLogger() *logger.Logger {
+ log, _ := logger.NewLogger(logger.LoggingConfig{
+ Level: "error",
+ Format: "json",
+ })
+ return log
+}
+
+// initGitRepo creates a minimal git repo with an initial commit and returns the path.
+func initGitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ // Start from a clean env: filter all GIT_* vars that may leak from parent
+ // processes (e.g. pre-commit hooks set GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE).
+ gitEnv := filterLocalTestGitEnv(os.Environ())
+ gitEnv = append(gitEnv,
+ "GIT_AUTHOR_NAME=Test",
+ "GIT_AUTHOR_EMAIL=test@test.local",
+ "GIT_COMMITTER_NAME=Test",
+ "GIT_COMMITTER_EMAIL=test@test.local",
+ "HOME="+dir,
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_NOSYSTEM=1",
+ )
+ for _, args := range [][]string{
+ {"init", "-b", "main"},
+ {"config", "core.hooksPath", "/dev/null"},
+ {"commit", "--allow-empty", "-m", "init"},
+ } {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = gitEnv
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %s", args, out)
+ }
+ }
+ return dir
+}
+
+// filterLocalTestGitEnv removes GIT_* environment variables that can leak from
+// parent processes (especially git hooks) and cause test git operations to
+// modify the wrong repository.
+func filterLocalTestGitEnv(env []string) []string {
+ filtered := make([]string, 0, len(env))
+ for _, e := range env {
+ key, _, _ := strings.Cut(e, "=")
+ if strings.HasPrefix(key, "GIT_") {
+ continue
+ }
+ filtered = append(filtered, e)
+ }
+ return filtered
+}
+
+// newIsolatedGitEnv returns a clean environment for test git commands that
+// filters leaked GIT_* vars and re-adds isolation + committer identity vars.
+func newIsolatedGitEnv() []string {
+ env := filterLocalTestGitEnv(os.Environ())
+ return append(env,
+ "GIT_AUTHOR_NAME=Test",
+ "GIT_AUTHOR_EMAIL=test@test.local",
+ "GIT_COMMITTER_NAME=Test",
+ "GIT_COMMITTER_EMAIL=test@test.local",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ "GIT_CONFIG_NOSYSTEM=1",
+ )
+}
+
+func currentBranch(t *testing.T, repoDir string) string {
+ t.Helper()
+ cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
+ cmd.Dir = repoDir
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("git rev-parse failed: %v", err)
+ }
+ return string(out[:len(out)-1]) // trim newline
+}
+
+// isolateGitEnv sets env vars to prevent git from reading the user's global
+// config (which may have commit signing enabled) during tests, and clears
+// GIT_* vars that leak from parent git hooks (pre-commit sets GIT_DIR, etc.).
+func isolateGitEnv(t *testing.T) {
+ t.Helper()
+ t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
+ // Unset vars set by git hooks that would redirect commands to the host repo.
+ // Cannot use t.Setenv("", "") because GIT_DIR="" makes git fail differently.
+ for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"} {
+ if val, ok := os.LookupEnv(key); ok {
+ _ = os.Unsetenv(key)
+ t.Cleanup(func() { _ = os.Setenv(key, val) })
+ }
+ }
+}
+
+func TestLocalPreparer_NoCheckoutBranch(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+
+ repoDir := initGitRepo(t)
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err != nil {
+ t.Fatalf("Prepare() error: %v", err)
+ }
+ if !result.Success {
+ t.Fatalf("Prepare() failed: %s", result.ErrorMessage)
+ }
+ // Only 1 step: validate workspace (no checkout step)
+ if len(result.Steps) != 1 {
+ t.Fatalf("expected 1 step, got %d", len(result.Steps))
+ }
+ if result.Steps[0].Name != "Validate workspace" {
+ t.Fatalf("expected step name 'Validate workspace', got %q", result.Steps[0].Name)
+ }
+ // Branch should still be main
+ if branch := currentBranch(t, repoDir); branch != "main" {
+ t.Fatalf("expected branch 'main', got %q", branch)
+ }
+}
+
+func TestLocalPreparer_CheckoutBranchSuccess(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+
+ repoDir := initGitRepo(t)
+ // Create a feature branch
+ env := newIsolatedGitEnv()
+ for _, args := range [][]string{
+ {"checkout", "-b", "feature/pr-branch"},
+ {"commit", "--allow-empty", "-m", "pr commit"},
+ {"checkout", "main"},
+ } {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repoDir
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %s", args, out)
+ }
+ }
+
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ CheckoutBranch: "feature/pr-branch",
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err != nil {
+ t.Fatalf("Prepare() error: %v", err)
+ }
+ if !result.Success {
+ t.Fatalf("Prepare() failed: %s", result.ErrorMessage)
+ }
+ // 2 steps: validate workspace + checkout branch
+ if len(result.Steps) != 2 {
+ t.Fatalf("expected 2 steps, got %d", len(result.Steps))
+ }
+ if result.Steps[1].Name != "Checkout branch" {
+ t.Fatalf("expected step name 'Checkout branch', got %q", result.Steps[1].Name)
+ }
+ if result.Steps[1].Status != PrepareStepCompleted {
+ t.Fatalf("expected checkout step completed, got %q", result.Steps[1].Status)
+ }
+ // Branch should be feature/pr-branch
+ if branch := currentBranch(t, repoDir); branch != "feature/pr-branch" {
+ t.Fatalf("expected branch 'feature/pr-branch', got %q", branch)
+ }
+}
+
+func TestLocalPreparer_CheckoutBranchNotFound(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+
+ repoDir := initGitRepo(t)
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ CheckoutBranch: "nonexistent-branch",
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err == nil {
+ t.Fatal("Prepare() should fail when branch doesn't exist")
+ }
+ if result.Success {
+ t.Fatal("expected result.Success = false")
+ }
+ // 2 steps: validate (completed) + checkout (failed)
+ if len(result.Steps) != 2 {
+ t.Fatalf("expected 2 steps, got %d", len(result.Steps))
+ }
+ if result.Steps[1].Status != PrepareStepFailed {
+ t.Fatalf("expected checkout step failed, got %q", result.Steps[1].Status)
+ }
+ // Branch should still be main (checkout failed)
+ if branch := currentBranch(t, repoDir); branch != "main" {
+ t.Fatalf("expected branch 'main' after failed checkout, got %q", branch)
+ }
+}
+
+func TestLocalPreparer_CheckoutBranchWithSetupScript(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+
+ repoDir := initGitRepo(t)
+ env := newIsolatedGitEnv()
+ for _, args := range [][]string{
+ {"checkout", "-b", "feature/pr-branch"},
+ {"commit", "--allow-empty", "-m", "pr commit"},
+ {"checkout", "main"},
+ } {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repoDir
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %s", args, out)
+ }
+ }
+
+ markerFile := filepath.Join(repoDir, "setup-ran")
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ CheckoutBranch: "feature/pr-branch",
+ SetupScript: "touch " + markerFile,
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err != nil {
+ t.Fatalf("Prepare() error: %v", err)
+ }
+ if !result.Success {
+ t.Fatalf("Prepare() failed: %s", result.ErrorMessage)
+ }
+ // 3 steps: validate + checkout + setup script
+ if len(result.Steps) != 3 {
+ t.Fatalf("expected 3 steps, got %d", len(result.Steps))
+ }
+ // Branch checked out before script runs
+ if branch := currentBranch(t, repoDir); branch != "feature/pr-branch" {
+ t.Fatalf("expected branch 'feature/pr-branch', got %q", branch)
+ }
+ // Script ran
+ if _, err := os.Stat(markerFile); os.IsNotExist(err) {
+ t.Fatal("setup script did not run")
+ }
+}
+
+func TestLocalPreparer_CheckoutWithDirtyWorkdir(t *testing.T) {
+ isolateGitEnv(t)
+ log := newTestLocalLogger()
+ preparer := NewLocalPreparer(log)
+
+ repoDir := initGitRepo(t)
+ env := newIsolatedGitEnv()
+ // Create feature branch
+ for _, args := range [][]string{
+ {"checkout", "-b", "feature/pr-branch"},
+ {"commit", "--allow-empty", "-m", "pr commit"},
+ {"checkout", "main"},
+ } {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repoDir
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v failed: %s", args, out)
+ }
+ }
+
+ // Create a dirty file that conflicts with checkout
+ dirtyFile := filepath.Join(repoDir, "dirty.txt")
+ if err := os.WriteFile(dirtyFile, []byte("dirty"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Stage and modify to create a conflict scenario
+ addCmd := exec.Command("git", "add", "dirty.txt")
+ addCmd.Dir = repoDir
+ addCmd.Env = env
+ if out, err := addCmd.CombinedOutput(); err != nil {
+ t.Fatalf("git add failed: %s", out)
+ }
+
+ // Commit on main so both branches diverge on this file
+ commitCmd := exec.Command("git", "commit", "-m", "add dirty on main")
+ commitCmd.Dir = repoDir
+ commitCmd.Env = env
+ if out, err := commitCmd.CombinedOutput(); err != nil {
+ t.Fatalf("git commit failed: %s", out)
+ }
+
+ // Now modify the file to make checkout fail
+ if err := os.WriteFile(dirtyFile, []byte("modified"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ req := &EnvPrepareRequest{
+ TaskID: "task-1",
+ RepositoryPath: repoDir,
+ CheckoutBranch: "feature/pr-branch",
+ }
+
+ result, err := preparer.Prepare(context.Background(), req, nil)
+ if err == nil {
+ t.Fatal("Prepare() should fail with dirty workdir")
+ }
+ if result.Success {
+ t.Fatal("expected result.Success = false")
+ }
+ // Should have 2 steps: validate (completed) + checkout (failed)
+ if len(result.Steps) != 2 {
+ t.Fatalf("expected 2 steps, got %d", len(result.Steps))
+ }
+ if result.Steps[1].Status != PrepareStepFailed {
+ t.Fatalf("expected checkout step failed, got %q", result.Steps[1].Status)
+ }
+ // Branch should still be main
+ if branch := currentBranch(t, repoDir); branch != "main" {
+ t.Fatalf("expected branch 'main' after failed checkout, got %q", branch)
+ }
+}
diff --git a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
index 6f2e4c2504..b7c9b7d528 100644
--- a/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
+++ b/apps/backend/internal/agent/lifecycle/env_preparer_worktree.go
@@ -132,6 +132,10 @@ func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest,
if req.CheckoutBranch != "" {
step = beginStep("Fetch PR branch")
reportProgress(onProgress, step, stepIdx, totalSteps)
+ if wt.FetchWarning != "" {
+ step.Warning = wt.FetchWarning
+ step.WarningDetail = wt.FetchWarningDetail
+ }
completeStepSuccess(&step)
steps = append(steps, step)
reportProgress(onProgress, step, stepIdx, totalSteps)
diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go
index 5a39d4483b..adb2373617 100644
--- a/apps/backend/internal/agent/lifecycle/event_types.go
+++ b/apps/backend/internal/agent/lifecycle/event_types.go
@@ -42,16 +42,18 @@ type ACPSessionCreatedPayload struct {
// PrepareProgressEventPayload is the payload for environment preparation progress events.
type PrepareProgressEventPayload struct {
- TaskID string `json:"task_id"`
- SessionID string `json:"session_id"`
- ExecutionID string `json:"execution_id"`
- StepName string `json:"step_name"`
- StepIndex int `json:"step_index"`
- TotalSteps int `json:"total_steps"`
- Status string `json:"status"`
- Output string `json:"output,omitempty"`
- Error string `json:"error,omitempty"`
- Timestamp string `json:"timestamp"`
+ TaskID string `json:"task_id"`
+ SessionID string `json:"session_id"`
+ ExecutionID string `json:"execution_id"`
+ StepName string `json:"step_name"`
+ StepIndex int `json:"step_index"`
+ TotalSteps int `json:"total_steps"`
+ Status string `json:"status"`
+ Output string `json:"output,omitempty"`
+ Error string `json:"error,omitempty"`
+ Warning string `json:"warning,omitempty"`
+ WarningDetail string `json:"warning_detail,omitempty"`
+ Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
@@ -61,14 +63,15 @@ func (p PrepareProgressEventPayload) GetSessionID() string {
// PrepareCompletedEventPayload is the payload when environment preparation finishes.
type PrepareCompletedEventPayload struct {
- TaskID string `json:"task_id"`
- SessionID string `json:"session_id"`
- ExecutionID string `json:"execution_id"`
- Success bool `json:"success"`
- ErrorMessage string `json:"error_message,omitempty"`
- DurationMs int64 `json:"duration_ms"`
- WorkspacePath string `json:"workspace_path,omitempty"`
- Timestamp string `json:"timestamp"`
+ TaskID string `json:"task_id"`
+ SessionID string `json:"session_id"`
+ ExecutionID string `json:"execution_id"`
+ Success bool `json:"success"`
+ ErrorMessage string `json:"error_message,omitempty"`
+ DurationMs int64 `json:"duration_ms"`
+ WorkspacePath string `json:"workspace_path,omitempty"`
+ Steps []PrepareStep `json:"steps,omitempty"`
+ Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
diff --git a/apps/backend/internal/agent/lifecycle/manager_launch.go b/apps/backend/internal/agent/lifecycle/manager_launch.go
index fd37ccd1a0..8d8977e9fb 100644
--- a/apps/backend/internal/agent/lifecycle/manager_launch.go
+++ b/apps/backend/internal/agent/lifecycle/manager_launch.go
@@ -153,14 +153,16 @@ func (m *Manager) launchPrepareRequest(req *LaunchRequest, profileInfo *AgentPro
func (m *Manager) newProgressCallback(taskID, sessionID string) PrepareProgressCallback {
return func(step PrepareStep, stepIndex int, totalSteps int) {
m.eventPublisher.PublishPrepareProgress(sessionID, &PrepareProgressEventPayload{
- TaskID: taskID,
- SessionID: sessionID,
- StepName: step.Name,
- StepIndex: stepIndex,
- TotalSteps: totalSteps,
- Status: string(step.Status),
- Output: step.Output,
- Error: step.Error,
+ TaskID: taskID,
+ SessionID: sessionID,
+ StepName: step.Name,
+ StepIndex: stepIndex,
+ TotalSteps: totalSteps,
+ Status: string(step.Status),
+ Output: step.Output,
+ Error: step.Error,
+ Warning: step.Warning,
+ WarningDetail: step.WarningDetail,
})
}
}
@@ -291,6 +293,7 @@ func (m *Manager) launchApplyPrepareResult(
SessionID: req.SessionID,
Success: false,
ErrorMessage: result.ErrorMessage,
+ Steps: result.Steps,
})
return fmt.Errorf("environment preparation failed: %s", result.ErrorMessage)
}
@@ -312,6 +315,7 @@ func (m *Manager) launchApplyPrepareResult(
Success: true,
DurationMs: result.Duration.Milliseconds(),
WorkspacePath: result.WorkspacePath,
+ Steps: result.Steps,
})
return nil
}
diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go
index b0137d913f..03a348773f 100644
--- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go
+++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go
@@ -16,9 +16,28 @@ import (
// setupTestRepo creates a git repository with a remote for testing.
// Returns the repo path and a cleanup function.
+// isolateTestGitEnv unsets GIT_* env vars that may be set by parent git hooks
+// (pre-commit). This protects both test helper commands AND the WorkspaceTracker's
+// internal git commands from operating on the wrong repository.
+// Cannot use t.Setenv("", "") because GIT_DIR="" makes git fail differently.
+func isolateTestGitEnv(t *testing.T) {
+ t.Helper()
+ for _, key := range []string{
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE",
+ "GIT_AUTHOR_DATE", "GIT_COMMITTER_DATE",
+ } {
+ if val, ok := os.LookupEnv(key); ok {
+ _ = os.Unsetenv(key)
+ t.Cleanup(func() { _ = os.Setenv(key, val) })
+ }
+ }
+}
+
func setupTestRepo(t *testing.T) (string, func()) {
t.Helper()
+ isolateTestGitEnv(t)
+
// Create temp directory for the "remote" bare repo
remoteDir, err := os.MkdirTemp("", "test-remote-*")
if err != nil {
@@ -259,6 +278,8 @@ func TestGetGitStatus_AheadBehindWithoutUpstream(t *testing.T) {
// 3. User does git fetch && git reset --hard origin/main
// 4. The upstream commits should be filtered out
func TestFilterLocalCommits_PullAndResetScenario(t *testing.T) {
+ isolateTestGitEnv(t)
+
// Create temp directories
remoteDir, err := os.MkdirTemp("", "test-remote-*")
if err != nil {
diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go
index f2cfb02692..75bcacc53c 100644
--- a/apps/backend/internal/gateway/websocket/task_notifications.go
+++ b/apps/backend/internal/gateway/websocket/task_notifications.go
@@ -131,10 +131,10 @@ func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action
return nil
}
case ws.ActionExecutorPrepareProgress, ws.ActionExecutorPrepareCompleted:
- if sessionID != "" {
- b.hub.BroadcastToSession(sessionID, msg)
- return nil
- }
+ // Broadcast globally so prepare progress/warnings are available
+ // when the user navigates to the session page after task creation.
+ b.hub.Broadcast(msg)
+ return nil
}
b.hub.Broadcast(msg)
return nil
diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go
index 5481b5a52a..ef51abccb4 100644
--- a/apps/backend/internal/task/dto/requests.go
+++ b/apps/backend/internal/task/dto/requests.go
@@ -245,12 +245,13 @@ type GetTaskRequest struct {
}
type TaskRepositoryInput struct {
- RepositoryID string
- BaseBranch string
- LocalPath string
- Name string
- DefaultBranch string
- GitHubURL string
+ RepositoryID string
+ BaseBranch string
+ CheckoutBranch string
+ LocalPath string
+ Name string
+ DefaultBranch string
+ GitHubURL string
}
type CreateTaskRequest struct {
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index 4707e986e4..1cb03fe97c 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -101,12 +101,13 @@ func convertToServiceRepos(repos []dto.TaskRepositoryInput) []service.TaskReposi
result := make([]service.TaskRepositoryInput, len(repos))
for i, r := range repos {
result[i] = service.TaskRepositoryInput{
- RepositoryID: r.RepositoryID,
- BaseBranch: r.BaseBranch,
- LocalPath: r.LocalPath,
- Name: r.Name,
- DefaultBranch: r.DefaultBranch,
- GitHubURL: r.GitHubURL,
+ RepositoryID: r.RepositoryID,
+ BaseBranch: r.BaseBranch,
+ CheckoutBranch: r.CheckoutBranch,
+ LocalPath: r.LocalPath,
+ Name: r.Name,
+ DefaultBranch: r.DefaultBranch,
+ GitHubURL: r.GitHubURL,
}
}
return result
diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go
index 9272b6908d..52bff77190 100644
--- a/apps/backend/internal/task/handlers/task_http_handlers.go
+++ b/apps/backend/internal/task/handlers/task_http_handlers.go
@@ -284,12 +284,13 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
}
type httpTaskRepositoryInput struct {
- RepositoryID string `json:"repository_id"`
- BaseBranch string `json:"base_branch"`
- LocalPath string `json:"local_path"`
- Name string `json:"name"`
- DefaultBranch string `json:"default_branch"`
- GitHubURL string `json:"github_url"`
+ RepositoryID string `json:"repository_id"`
+ BaseBranch string `json:"base_branch"`
+ CheckoutBranch string `json:"checkout_branch"`
+ LocalPath string `json:"local_path"`
+ Name string `json:"name"`
+ DefaultBranch string `json:"default_branch"`
+ GitHubURL string `json:"github_url"`
}
type httpCreateTaskRequest struct {
@@ -377,12 +378,13 @@ func convertCreateTaskRepositories(c *gin.Context, inputs []httpTaskRepositoryIn
return nil, false
}
repos = append(repos, dto.TaskRepositoryInput{
- RepositoryID: r.RepositoryID,
- BaseBranch: r.BaseBranch,
- LocalPath: r.LocalPath,
- Name: r.Name,
- DefaultBranch: r.DefaultBranch,
- GitHubURL: r.GitHubURL,
+ RepositoryID: r.RepositoryID,
+ BaseBranch: r.BaseBranch,
+ CheckoutBranch: r.CheckoutBranch,
+ LocalPath: r.LocalPath,
+ Name: r.Name,
+ DefaultBranch: r.DefaultBranch,
+ GitHubURL: r.GitHubURL,
})
}
return repos, true
diff --git a/apps/backend/internal/worktree/errors.go b/apps/backend/internal/worktree/errors.go
index cbb05eb977..6ac5f3e4b6 100644
--- a/apps/backend/internal/worktree/errors.go
+++ b/apps/backend/internal/worktree/errors.go
@@ -1,7 +1,11 @@
// Package worktree provides Git worktree management for concurrent agent execution.
package worktree
-import "errors"
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
var (
// ErrWorktreeExists is returned when attempting to create a worktree that already exists.
@@ -30,4 +34,40 @@ var (
// ErrInvalidSession is returned when the session ID is invalid or empty.
ErrInvalidSession = errors.New("invalid or empty session ID")
+
+ // ErrBranchCheckedOut is returned when a branch is already checked out in another worktree.
+ ErrBranchCheckedOut = errors.New("branch is already checked out in another worktree")
+
+ // ErrAuthFailed is returned when git authentication fails (e.g. no credentials in non-interactive mode).
+ ErrAuthFailed = errors.New("git authentication failed")
+
+ // ErrNonFastForward is returned when a fetch/pull is rejected due to non-fast-forward updates.
+ ErrNonFastForward = errors.New("non-fast-forward update rejected")
)
+
+// containsAuthFailure checks if git output indicates an authentication failure.
+func containsAuthFailure(lowerOutput string) bool {
+ return strings.Contains(lowerOutput, "authentication failed") ||
+ strings.Contains(lowerOutput, "terminal prompts disabled") ||
+ strings.Contains(lowerOutput, "could not read username") ||
+ strings.Contains(lowerOutput, "username for 'https://") ||
+ strings.Contains(lowerOutput, "askpass")
+}
+
+// ClassifyGitError wraps a raw git error with a user-friendly sentinel error
+// based on the command output.
+func ClassifyGitError(output string, _ error) error {
+ out := strings.ToLower(output)
+ trimmed := strings.TrimSpace(output)
+
+ switch {
+ case strings.Contains(out, "is already checked out at"):
+ return fmt.Errorf("%w: %s", ErrBranchCheckedOut, trimmed)
+ case containsAuthFailure(out):
+ return fmt.Errorf("%w: %s", ErrAuthFailed, trimmed)
+ case strings.Contains(out, "non-fast-forward"):
+ return fmt.Errorf("%w: %s", ErrNonFastForward, trimmed)
+ default:
+ return fmt.Errorf("%w: %s", ErrGitCommandFailed, trimmed)
+ }
+}
diff --git a/apps/backend/internal/worktree/errors_test.go b/apps/backend/internal/worktree/errors_test.go
new file mode 100644
index 0000000000..763641ad96
--- /dev/null
+++ b/apps/backend/internal/worktree/errors_test.go
@@ -0,0 +1,104 @@
+package worktree
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestClassifyGitError_BranchCheckedOut(t *testing.T) {
+ output := "fatal: 'feature/pr-branch' is already checked out at '/tmp/worktree-123'"
+ err := ClassifyGitError(output, fmt.Errorf("exit status 128"))
+ if !errors.Is(err, ErrBranchCheckedOut) {
+ t.Fatalf("expected ErrBranchCheckedOut, got: %v", err)
+ }
+}
+
+func TestClassifyGitError_AuthFailed(t *testing.T) {
+ tests := []struct {
+ name string
+ output string
+ }{
+ {"could not read username", "fatal: could not read Username for 'https://github.com': terminal prompts disabled"},
+ {"authentication failed", "fatal: Authentication failed for 'https://github.com/repo.git'"},
+ {"askpass", "fatal: could not read Password via askpass"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ClassifyGitError(tt.output, fmt.Errorf("exit status 128"))
+ if !errors.Is(err, ErrAuthFailed) {
+ t.Fatalf("expected ErrAuthFailed, got: %v", err)
+ }
+ })
+ }
+}
+
+func TestClassifyGitError_NonFastForward(t *testing.T) {
+ output := "! [rejected] main -> main (non-fast-forward)"
+ err := ClassifyGitError(output, fmt.Errorf("exit status 1"))
+ if !errors.Is(err, ErrNonFastForward) {
+ t.Fatalf("expected ErrNonFastForward, got: %v", err)
+ }
+}
+
+func TestClassifyGitError_GenericFailure(t *testing.T) {
+ output := "fatal: some unknown git error"
+ err := ClassifyGitError(output, fmt.Errorf("exit status 1"))
+ if !errors.Is(err, ErrGitCommandFailed) {
+ t.Fatalf("expected ErrGitCommandFailed, got: %v", err)
+ }
+}
+
+func TestContainsAuthFailure(t *testing.T) {
+ tests := []struct {
+ input string
+ expected bool
+ }{
+ {"authentication failed", true},
+ {"terminal prompts disabled", true},
+ {"could not read username", true},
+ {"username for 'https://github.com'", true},
+ {"askpass error", true},
+ {"branch not found", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ if got := containsAuthFailure(tt.input); got != tt.expected {
+ t.Fatalf("containsAuthFailure(%q) = %v, want %v", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestParseCheckedOutPath(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ "standard git output",
+ "fatal: 'feature/pr-branch' is already checked out at '/tmp/worktrees/my-worktree'",
+ "/tmp/worktrees/my-worktree",
+ },
+ {
+ "no match",
+ "fatal: some other error",
+ "",
+ },
+ {
+ "no closing quote",
+ "fatal: 'branch' is already checked out at '/path",
+ "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseCheckedOutPath(tt.input)
+ if got != tt.expected {
+ t.Fatalf("parseCheckedOutPath(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/apps/backend/internal/worktree/manager.go b/apps/backend/internal/worktree/manager.go
index 56287b66b7..23dd4f4e15 100644
--- a/apps/backend/internal/worktree/manager.go
+++ b/apps/backend/internal/worktree/manager.go
@@ -249,30 +249,42 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Worktree, err
// createWorktree performs the actual git worktree creation.
func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef string) (*Worktree, error) {
+ worktreeDirName, branchName := m.buildWorktreeNames(req)
+
// If a checkout branch is specified (e.g. a PR's head branch), fetch it
- // first and use it as the base so the new worktree branch is created on
- // top of the PR branch rather than on top of the default branch.
+ // and checkout it directly in the worktree instead of creating a new branch.
+ var fetchResult *FetchBranchResult
if req.CheckoutBranch != "" {
- fetchedRef, err := m.fetchBranchRef(ctx, req.RepositoryPath, req.CheckoutBranch)
+ var err error
+ fetchResult, err = m.fetchBranchToLocal(ctx, req.RepositoryPath, req.CheckoutBranch)
if err != nil {
return nil, err
}
- baseRef = fetchedRef
+ branchName = req.CheckoutBranch
}
- worktreeDirName, branchName := m.buildWorktreeNames(req)
-
worktreePath, err := m.config.WorktreePath(worktreeDirName)
if err != nil {
return nil, fmt.Errorf("failed to get worktree path: %w", err)
}
- worktreeID, err := m.gitAddWorktree(ctx, req.RepositoryPath, branchName, worktreePath, baseRef)
+ var worktreeID string
+ if req.CheckoutBranch != "" {
+ // Checkout the existing PR branch directly in the worktree.
+ worktreeID, err = m.gitAddWorktreeExisting(ctx, req.RepositoryPath, branchName, worktreePath)
+ } else {
+ // Create a new branch based on the base ref.
+ worktreeID, err = m.gitAddWorktree(ctx, req.RepositoryPath, branchName, worktreePath, baseRef)
+ }
if err != nil {
return nil, err
}
wt := m.buildWorktreeRecord(worktreeID, req, worktreePath, branchName)
+ if fetchResult != nil {
+ wt.FetchWarning = fetchResult.Warning
+ wt.FetchWarningDetail = fetchResult.WarningDetail
+ }
if err := m.persistAndCacheWorktree(ctx, wt, req, worktreePath); err != nil {
return nil, err
@@ -291,24 +303,136 @@ func (m *Manager) createWorktree(ctx context.Context, req CreateRequest, baseRef
return wt, nil
}
-// fetchBranchRef fetches a branch from origin in the given repository and returns
-// the FETCH_HEAD ref that can be used as the base for a new worktree branch.
-// This is used for PR reviews so the new worktree branch is created on top of
-// the PR's head branch.
-func (m *Manager) fetchBranchRef(ctx context.Context, repoPath, branch string) (string, error) {
- m.logger.Info("fetching branch for worktree base",
+// FetchBranchResult holds the outcome of a fetchBranchToLocal call.
+type FetchBranchResult struct {
+ Warning string // User-friendly warning (non-empty when fell back to local)
+ WarningDetail string // Raw git command output for debugging
+}
+
+// fetchBranchToLocal ensures a branch exists locally and is up-to-date.
+// It first tries to fetch from origin to get the latest commits. If the fetch
+// fails (no remote, auth issue, offline), it falls back to the local branch.
+// Returns a FetchBranchResult with warning info and an error if the branch
+// doesn't exist anywhere.
+func (m *Manager) fetchBranchToLocal(ctx context.Context, repoPath, branch string) (*FetchBranchResult, error) {
+ m.logger.Info("syncing checkout branch",
zap.String("branch", branch),
zap.String("repo_path", repoPath))
+ // Try to fetch from origin to get the latest version.
fetchCtx, cancelFetch := context.WithTimeout(ctx, 30*time.Second)
defer cancelFetch()
- fetchCmd := m.newNonInteractiveGitCmd(fetchCtx, repoPath, "fetch", "origin", branch)
+ fetchCmd := m.newNonInteractiveGitCmd(fetchCtx, repoPath, "fetch", "origin", branch+":"+branch)
if output, err := fetchCmd.CombinedOutput(); err != nil {
- return "", fmt.Errorf("failed to fetch branch %q: %s", branch, string(output))
+ m.logger.Warn("fetch from origin failed, checking local branch",
+ zap.String("branch", branch),
+ zap.String("output", string(output)),
+ zap.Error(err))
+
+ // Fall back to local branch if it exists.
+ if !m.branchExists(repoPath, branch) {
+ return nil, fmt.Errorf("branch %q not found locally or on remote: %s", branch, string(output))
+ }
+
+ reason := classifyGitFallbackReason(err, string(output), fetchCtx.Err())
+ warning := fmt.Sprintf("Could not fetch latest from origin (%s). Using local version of branch %q which may be outdated.", reason, branch)
+ m.logger.Info("using local branch (fetch failed)",
+ zap.String("branch", branch),
+ zap.String("warning", warning))
+ return &FetchBranchResult{
+ Warning: warning,
+ WarningDetail: strings.TrimSpace(string(output)),
+ }, nil
}
- return "FETCH_HEAD", nil
+ return &FetchBranchResult{}, nil
+}
+
+// gitAddWorktreeExisting creates a worktree that checks out an existing local branch.
+// If the branch is already checked out in a stale worktree (directory no longer exists),
+// it automatically prunes and retries.
+func (m *Manager) gitAddWorktreeExisting(ctx context.Context, repoPath, branchName, worktreePath string) (string, error) {
+ worktreeID := uuid.New().String()
+ cmd := exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branchName)
+ cmd.Dir = repoPath
+ output, err := cmd.CombinedOutput()
+ if err == nil {
+ return worktreeID, nil
+ }
+
+ outStr := string(output)
+ if !strings.Contains(strings.ToLower(outStr), "is already checked out at") {
+ m.logger.Error("git worktree add (existing branch) failed",
+ zap.String("output", outStr), zap.Error(err))
+ return "", ClassifyGitError(outStr, err)
+ }
+
+ if recoveryErr := m.tryRecoverCheckedOutBranch(ctx, repoPath, branchName, outStr); recoveryErr != nil {
+ m.logger.Error("git worktree add (existing branch) failed",
+ zap.String("output", outStr), zap.Error(err))
+ return "", ClassifyGitError(outStr, err)
+ }
+
+ // Retry after pruning stale worktree.
+ retryCmd := exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branchName)
+ retryCmd.Dir = repoPath
+ if retryOutput, retryErr := retryCmd.CombinedOutput(); retryErr != nil {
+ m.logger.Error("git worktree add retry failed",
+ zap.String("output", string(retryOutput)), zap.Error(retryErr))
+ return "", ClassifyGitError(string(retryOutput), retryErr)
+ }
+ m.logger.Info("recovered from stale worktree checkout", zap.String("branch", branchName))
+ return worktreeID, nil
+}
+
+// tryRecoverCheckedOutBranch attempts to recover from "branch is already checked out"
+// by pruning stale worktrees. Returns nil if recovery succeeded, error otherwise.
+func (m *Manager) tryRecoverCheckedOutBranch(ctx context.Context, repoPath, branchName, gitOutput string) error {
+ // Parse the path from git output: "fatal: 'branch' is already checked out at '/path/to/worktree'"
+ checkedOutPath := parseCheckedOutPath(gitOutput)
+ if checkedOutPath == "" {
+ return fmt.Errorf("could not parse worktree path from git output")
+ }
+
+ // Check if the worktree directory still exists on disk.
+ if _, err := os.Stat(checkedOutPath); err == nil {
+ // Directory exists — worktree is genuinely in use, can't recover.
+ m.logger.Warn("branch checked out in active worktree, cannot recover",
+ zap.String("branch", branchName),
+ zap.String("worktree_path", checkedOutPath))
+ return fmt.Errorf("worktree at %s is still active", checkedOutPath)
+ }
+
+ // Directory is gone — prune stale worktree references.
+ m.logger.Info("pruning stale worktree reference",
+ zap.String("branch", branchName),
+ zap.String("stale_path", checkedOutPath))
+
+ pruneCmd := exec.CommandContext(ctx, "git", "worktree", "prune")
+ pruneCmd.Dir = repoPath
+ if output, err := pruneCmd.CombinedOutput(); err != nil {
+ m.logger.Error("git worktree prune failed",
+ zap.String("output", string(output)),
+ zap.Error(err))
+ return fmt.Errorf("worktree prune failed: %w", err)
+ }
+ return nil
+}
+
+// parseCheckedOutPath extracts the worktree path from git's
+// "fatal: 'branch' is already checked out at '/path'" error message.
+func parseCheckedOutPath(gitOutput string) string {
+ const marker = "checked out at '"
+ _, after, found := strings.Cut(gitOutput, marker)
+ if !found {
+ return ""
+ }
+ path, _, found := strings.Cut(after, "'")
+ if !found {
+ return ""
+ }
+ return path
}
// buildWorktreeNames derives the filesystem directory name and git branch name for a new worktree.
@@ -801,12 +925,7 @@ func classifyGitFallbackReason(cmdErr error, cmdOutput string, ctxErr error) str
return "timeout"
}
- out := strings.ToLower(cmdOutput)
- if strings.Contains(out, "authentication failed") ||
- strings.Contains(out, "terminal prompts disabled") ||
- strings.Contains(out, "could not read username") ||
- strings.Contains(out, "username for 'https://") ||
- strings.Contains(out, "askpass") {
+ if containsAuthFailure(strings.ToLower(cmdOutput)) {
return "non_interactive_auth_failed"
}
diff --git a/apps/backend/internal/worktree/manager_test.go b/apps/backend/internal/worktree/manager_test.go
index 317faa47be..6c083113d9 100644
--- a/apps/backend/internal/worktree/manager_test.go
+++ b/apps/backend/internal/worktree/manager_test.go
@@ -779,6 +779,122 @@ esac
}
}
+func TestFetchBranchToLocal_FetchSucceeds(t *testing.T) {
+ scriptDir := writeFakeGitScript(t, `
+case "${1:-}" in
+ fetch)
+ # Simulate successful fetch
+ exit 0
+ ;;
+ *)
+ exit 0
+ ;;
+esac
+`)
+ t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cfg := newTestConfig(t)
+ log := newTestLogger()
+ store := newMockStore()
+ mgr, err := NewManager(cfg, store, log)
+ if err != nil {
+ t.Fatalf("NewManager failed: %v", err)
+ }
+
+ repoPath := t.TempDir()
+ result, err := mgr.fetchBranchToLocal(context.Background(), repoPath, "feature/pr-branch")
+ if err != nil {
+ t.Fatalf("fetchBranchToLocal() unexpected error: %v", err)
+ }
+ if result.Warning != "" {
+ t.Fatalf("expected no warning on successful fetch, got %q", result.Warning)
+ }
+}
+
+func TestFetchBranchToLocal_FetchFailsLocalBranchExists(t *testing.T) {
+ scriptDir := writeFakeGitScript(t, `
+case "${1:-}" in
+ fetch)
+ echo "fatal: no remote configured" >&2
+ exit 1
+ ;;
+ rev-parse)
+ # Simulate branch exists locally
+ if [ "${2:-}" = "--verify" ]; then
+ exit 0
+ fi
+ exit 0
+ ;;
+ *)
+ exit 0
+ ;;
+esac
+`)
+ t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cfg := newTestConfig(t)
+ log := newTestLogger()
+ store := newMockStore()
+ mgr, err := NewManager(cfg, store, log)
+ if err != nil {
+ t.Fatalf("NewManager failed: %v", err)
+ }
+
+ repoPath := t.TempDir()
+ result, err := mgr.fetchBranchToLocal(context.Background(), repoPath, "feature/pr-branch")
+ if err != nil {
+ t.Fatalf("fetchBranchToLocal() should fall back to local branch, got error: %v", err)
+ }
+ if result.Warning == "" {
+ t.Fatal("expected a warning when falling back to local branch")
+ }
+ if !strings.Contains(result.Warning, "Could not fetch latest from origin") {
+ t.Fatalf("unexpected warning: %q", result.Warning)
+ }
+ if result.WarningDetail == "" {
+ t.Fatal("expected warning detail with raw git output")
+ }
+}
+
+func TestFetchBranchToLocal_FetchFailsNoBranch(t *testing.T) {
+ scriptDir := writeFakeGitScript(t, `
+case "${1:-}" in
+ fetch)
+ echo "fatal: no remote configured" >&2
+ exit 1
+ ;;
+ rev-parse)
+ # Simulate branch does NOT exist
+ if [ "${2:-}" = "--verify" ]; then
+ exit 1
+ fi
+ exit 0
+ ;;
+ *)
+ exit 0
+ ;;
+esac
+`)
+ t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ cfg := newTestConfig(t)
+ log := newTestLogger()
+ store := newMockStore()
+ mgr, err := NewManager(cfg, store, log)
+ if err != nil {
+ t.Fatalf("NewManager failed: %v", err)
+ }
+
+ repoPath := t.TempDir()
+ _, err = mgr.fetchBranchToLocal(context.Background(), repoPath, "feature/pr-branch")
+ if err == nil {
+ t.Fatal("fetchBranchToLocal() should fail when branch not found anywhere")
+ }
+ if !strings.Contains(err.Error(), "not found locally or on remote") {
+ t.Fatalf("unexpected error message: %v", err)
+ }
+}
+
func TestClassifyGitFallbackReason_AuthPrompt(t *testing.T) {
reason := classifyGitFallbackReason(nil, "fatal: could not read Username for 'https://github.com'", nil)
if reason != "non_interactive_auth_failed" {
diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go
index 657ad9773c..cd997c8b34 100644
--- a/apps/backend/internal/worktree/worktree.go
+++ b/apps/backend/internal/worktree/worktree.go
@@ -64,6 +64,14 @@ type Worktree struct {
// DeletedAt is when this worktree was deleted (if applicable).
DeletedAt *time.Time `json:"deleted_at,omitempty"`
+
+ // FetchWarning is a non-fatal warning from fetching the checkout branch.
+ // Set when fetch from origin failed but a local branch was used as fallback.
+ FetchWarning string `json:"fetch_warning,omitempty"`
+
+ // FetchWarningDetail contains the raw git command output for debugging.
+ // Shown as collapsible content alongside the user-friendly FetchWarning.
+ FetchWarningDetail string `json:"fetch_warning_detail,omitempty"`
}
// CreateRequest contains the parameters for creating a new worktree.
diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx
index d75f8268a2..697102aa7d 100644
--- a/apps/web/components/session/prepare-progress.tsx
+++ b/apps/web/components/session/prepare-progress.tsx
@@ -1,6 +1,14 @@
"use client";
-import { IconCheck, IconX, IconLoader2 } from "@tabler/icons-react";
+import { useState } from "react";
+import {
+ IconCheck,
+ IconX,
+ IconLoader2,
+ IconAlertTriangle,
+ IconChevronDown,
+ IconChevronRight,
+} from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types";
@@ -8,7 +16,10 @@ type PrepareProgressProps = {
sessionId: string;
};
-function StepIcon({ status }: { status: string }) {
+function StepIcon({ status, hasWarning }: { status: string; hasWarning?: boolean }) {
+ if (status === "completed" && hasWarning) {
+ return
+ {step.warning}
+
+ {step.warningDetail && (
+ <>
+
+ {detailExpanded && (
+
+ {step.warningDetail}
+
+ )}
+ >
+ )}
+
{step.error}
@@ -54,10 +94,32 @@ function useEffectivePrepareStatus(sessionId: string) {
return profile?.label ?? null;
});
+ const hasWarnings = prepareState?.steps.some((s) => s.warning);
+
if (!prepareState)
- return { visible: false, status: "preparing", prepareState, profileLabel } as const;
- if (prepareState.status === "completed")
- return { visible: false, status: "completed", prepareState, profileLabel } as const;
+ return {
+ visible: false,
+ status: "preparing",
+ prepareState,
+ profileLabel,
+ hasWarnings: false,
+ } as const;
+ if (prepareState.status === "completed" && !hasWarnings)
+ return {
+ visible: false,
+ status: "completed",
+ prepareState,
+ profileLabel,
+ hasWarnings: false,
+ } as const;
+ if (prepareState.status === "completed" && hasWarnings)
+ return {
+ visible: true,
+ status: "completed_with_warnings",
+ prepareState,
+ profileLabel,
+ hasWarnings: true,
+ } as const;
// If session reached a terminal state but prepare status is still "preparing",
// treat it as failed (the completed event may not have arrived)
@@ -66,15 +128,75 @@ function useEffectivePrepareStatus(sessionId: string) {
const status =
prepareState.status === "preparing" && isSessionTerminal ? "failed" : prepareState.status;
- return { visible: true, status, prepareState, profileLabel } as const;
+ return {
+ visible: true,
+ status,
+ prepareState,
+ profileLabel,
+ hasWarnings: hasWarnings ?? false,
+ } as const;
}
export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const { visible, status, prepareState, profileLabel } = useEffectivePrepareStatus(sessionId);
+ const [dismissed, setDismissed] = useState(false);
+ const [expandedDetails, setExpandedDetails] = useState>({});
if (!visible || !prepareState) return null;
+
+ // After successful completion with warnings, show only the warning steps.
+ if (status === "completed_with_warnings") {
+ if (dismissed) return null;
+ const warningSteps = prepareState.steps
+ .map((s, i) => ({ step: s, index: i }))
+ .filter(({ step }) => step.warning);
+ return (
+
+ {warningSteps.map(({ step, index }) => (
+
+
+
+ {step.warning}
+ {step.warningDetail && (
+
+ )}
+
+
+ {step.warningDetail && expandedDetails[index] && (
+
+ {step.warningDetail}
+
+ )}
+
+ ))}
+
+ );
+ }
+
const visibleSteps = prepareState.steps.filter(
- (step) => step.name.trim() !== "" || Boolean(step.output) || Boolean(step.error),
+ (step) =>
+ step.name.trim() !== "" ||
+ Boolean(step.output) ||
+ Boolean(step.error) ||
+ Boolean(step.warning),
);
return (
diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx
index bf6cc4744e..264a779b27 100644
--- a/apps/web/components/task-create-dialog-submit.tsx
+++ b/apps/web/components/task-create-dialog-submit.tsx
@@ -31,6 +31,7 @@ export type SubmitHandlersDeps = {
selectedLocalRepo: LocalRepository | null;
useGitHubUrl: boolean;
githubUrl: string;
+ githubPrHeadBranch: string | null;
branch: string;
agentProfileId: string;
executorId: string;
@@ -79,6 +80,7 @@ export function useTaskSubmitHandlers({
selectedLocalRepo,
useGitHubUrl,
githubUrl,
+ githubPrHeadBranch,
branch,
agentProfileId,
executorId,
@@ -136,6 +138,7 @@ export function useTaskSubmitHandlers({
{
repository_id: "",
base_branch: branch || undefined,
+ checkout_branch: githubPrHeadBranch || undefined,
github_url: githubUrl,
},
];
@@ -154,7 +157,7 @@ export function useTaskSubmitHandlers({
];
}
return [];
- }, [useGitHubUrl, repositoryId, branch, githubUrl, selectedLocalRepo]);
+ }, [useGitHubUrl, repositoryId, branch, githubUrl, githubPrHeadBranch, selectedLocalRepo]);
const handleSessionSubmit = useCallback(async () => {
const description = descriptionInputRef.current?.getValue() ?? "";
diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx
index a542504dea..e4684883ab 100644
--- a/apps/web/components/task-create-dialog.tsx
+++ b/apps/web/components/task-create-dialog.tsx
@@ -185,9 +185,9 @@ function DialogHeaderContent(props: DialogHeaderContentProps) {
if (isCreateMode || isEditMode) {
return (
-
-
-
+ <>
+
+
/
- {!isTaskStarted && (
-
-
-
- )}
-
-
+
+ {!isTaskStarted && (
+
+
+
+ )}
+ >
);
}
return (
@@ -407,6 +407,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
selectedLocalRepo: fs.selectedLocalRepo,
useGitHubUrl: fs.useGitHubUrl,
githubUrl: fs.githubUrl,
+ githubPrHeadBranch: fs.githubPrHeadBranch,
branch: fs.branch,
agentProfileId: fs.agentProfileId,
executorId: fs.executorId,
diff --git a/apps/web/e2e/tests/create-task-github-url.spec.ts b/apps/web/e2e/tests/create-task-github-url.spec.ts
index 1226206ee7..f2d0d2740f 100644
--- a/apps/web/e2e/tests/create-task-github-url.spec.ts
+++ b/apps/web/e2e/tests/create-task-github-url.spec.ts
@@ -334,6 +334,413 @@ test.describe("Task creation from GitHub URL", () => {
});
});
+ test("PR URL auto-selects the PR head branch", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ // Pre-seed a GitHub-backed repository
+ const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
+ await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
+ name: "test-owner/test-repo",
+ provider: "github",
+ provider_owner: "test-owner",
+ provider_name: "test-repo",
+ });
+
+ // Seed branches including the PR head branch
+ await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [
+ { name: "main" },
+ { name: "feature/pr-branch" },
+ ]);
+
+ // Seed a PR pointing to the feature branch
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 42,
+ title: "Test PR",
+ state: "open",
+ head_branch: "feature/pr-branch",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "test-owner",
+ repo_name: "test-repo",
+ },
+ ]);
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ await kanban.createTaskButton.first().click();
+ const dialog = testPage.getByTestId("create-task-dialog");
+ await expect(dialog).toBeVisible();
+
+ // Toggle to GitHub URL mode and enter a PR URL
+ await testPage.getByTestId("toggle-github-url").click();
+ await testPage
+ .getByTestId("github-url-input")
+ .fill("https://github.com/test-owner/test-repo/pull/42");
+
+ // The PR head branch should be auto-selected in the branch selector
+ await expect(dialog.getByText("feature/pr-branch")).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("creates task from PR URL with local executor end-to-end", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ test.setTimeout(90_000);
+
+ const { execSync } = await import("child_process");
+ const gitEnv = {
+ ...process.env,
+ HOME: backend.tmpDir,
+ GIT_AUTHOR_NAME: "E2E Test",
+ GIT_AUTHOR_EMAIL: "e2e@test.local",
+ GIT_COMMITTER_NAME: "E2E Test",
+ GIT_COMMITTER_EMAIL: "e2e@test.local",
+ };
+
+ // Create the feature/pr-branch in the local repo so git checkout works
+ const repoDir = `${backend.tmpDir}/repos/e2e-repo`;
+ execSync("git checkout -b feature/pr-branch", { cwd: repoDir, env: gitEnv });
+ execSync('git commit --allow-empty -m "pr commit"', { cwd: repoDir, env: gitEnv });
+ execSync("git checkout main", { cwd: repoDir, env: gitEnv });
+
+ // Pre-seed a GitHub-backed repository
+ await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
+ name: "test-owner/test-repo",
+ provider: "github",
+ provider_owner: "test-owner",
+ provider_name: "test-repo",
+ });
+
+ await apiClient.mockGitHubAddBranches("test-owner", "test-repo", [
+ { name: "main" },
+ { name: "feature/pr-branch" },
+ ]);
+
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 99,
+ title: "Feature PR",
+ state: "open",
+ head_branch: "feature/pr-branch",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "test-owner",
+ repo_name: "test-repo",
+ },
+ ]);
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ await kanban.createTaskButton.first().click();
+ const dialog = testPage.getByTestId("create-task-dialog");
+ await expect(dialog).toBeVisible();
+
+ // Toggle to GitHub URL mode and enter the PR URL
+ await testPage.getByTestId("toggle-github-url").click();
+ await testPage
+ .getByTestId("github-url-input")
+ .fill("https://github.com/test-owner/test-repo/pull/99");
+
+ // Verify PR head branch is auto-selected
+ await expect(dialog.getByText("feature/pr-branch")).toBeVisible({ timeout: 10_000 });
+
+ // Fill in title and description
+ await testPage.getByTestId("task-title-input").fill("PR Task Local");
+ await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
+
+ // Wait for the start button to become enabled
+ const startBtn = testPage.getByTestId("submit-start-agent");
+ await expect(startBtn).toBeEnabled({ timeout: 15_000 });
+
+ // Submit (default executor is local/standalone)
+ await startBtn.click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ // The new task card appears on the kanban board
+ const card = kanban.taskCardByTitle("PR Task Local");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ // Navigate to the session and verify the agent completes
+ await card.click();
+ await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
+ timeout: 30_000,
+ });
+
+ await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
+
+ // Verify the local executor checked out the PR branch.
+ await expect(session.terminal).toBeVisible({ timeout: 15_000 });
+ await session.typeInTerminal("git branch --show-current");
+ await session.expectTerminalHasText("feature/pr-branch");
+ });
+
+ test("creates task from PR URL with worktree executor and verifies branch", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ test.setTimeout(90_000);
+
+ const { execSync } = await import("child_process");
+ const fs = await import("fs");
+ const gitEnv = {
+ ...process.env,
+ HOME: backend.tmpDir,
+ GIT_AUTHOR_NAME: "E2E Test",
+ GIT_AUTHOR_EMAIL: "e2e@test.local",
+ GIT_COMMITTER_NAME: "E2E Test",
+ GIT_COMMITTER_EMAIL: "e2e@test.local",
+ };
+
+ // Create a fresh repo with the PR branch available locally.
+ // No remote needed — the worktree manager falls back to local branches.
+ const repoDir = `${backend.tmpDir}/repos/e2e-pr-wt`;
+ fs.mkdirSync(repoDir, { recursive: true });
+
+ execSync("git init -b main", { cwd: repoDir, env: gitEnv });
+ execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
+
+ // Create the PR branch locally and switch back to main
+ execSync("git checkout -b feature/pr-branch", { cwd: repoDir, env: gitEnv });
+ execSync('git commit --allow-empty -m "pr branch commit"', { cwd: repoDir, env: gitEnv });
+ execSync("git checkout main", { cwd: repoDir, env: gitEnv });
+
+ // Register the repo with a unique provider name to avoid collisions with
+ // other tests that also register repos as test-owner/test-repo.
+ await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
+ name: "pr-owner/pr-wt-repo",
+ provider: "github",
+ provider_owner: "pr-owner",
+ provider_name: "pr-wt-repo",
+ });
+
+ // Seed mock GitHub branches and PR
+ await apiClient.mockGitHubAddBranches("pr-owner", "pr-wt-repo", [
+ { name: "main" },
+ { name: "feature/pr-branch" },
+ ]);
+
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 77,
+ title: "Worktree PR",
+ state: "open",
+ head_branch: "feature/pr-branch",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "pr-owner",
+ repo_name: "pr-wt-repo",
+ },
+ ]);
+
+ // Look up the worktree executor profile
+ const { executors } = await apiClient.listExecutors();
+ const worktreeExec = executors.find((e) => e.type === "worktree");
+ const worktreeProfile = worktreeExec?.profiles?.[0];
+ if (!worktreeProfile) {
+ test.skip(true, "No worktree executor profile available");
+ return;
+ }
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ await kanban.createTaskButton.first().click();
+ const dialog = testPage.getByTestId("create-task-dialog");
+ await expect(dialog).toBeVisible();
+
+ // Toggle to GitHub URL mode and enter the PR URL
+ await testPage.getByTestId("toggle-github-url").click();
+ await testPage
+ .getByTestId("github-url-input")
+ .fill("https://github.com/pr-owner/pr-wt-repo/pull/77");
+
+ // Verify PR head branch is auto-selected
+ await expect(dialog.getByText("feature/pr-branch")).toBeVisible({ timeout: 10_000 });
+
+ // Fill in title and description
+ await testPage.getByTestId("task-title-input").fill("PR Worktree Task");
+ await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
+
+ // Wait for selectors to be ready
+ const startBtn = testPage.getByTestId("submit-start-agent");
+ await expect(startBtn).toBeEnabled({ timeout: 15_000 });
+
+ // Select the worktree executor profile
+ const executorSelector = testPage.getByTestId("executor-profile-selector");
+ await executorSelector.click();
+ await testPage.getByRole("option", { name: /Worktree/i }).click();
+
+ // Submit
+ await startBtn.click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ const card = kanban.taskCardByTitle("PR Worktree Task");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ await card.click();
+ await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
+ timeout: 30_000,
+ });
+
+ await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
+
+ // Verify the worktree checked out the PR branch directly.
+ await expect(session.terminal).toBeVisible({ timeout: 15_000 });
+ await session.typeInTerminal("git branch --show-current");
+ await session.expectTerminalHasText("feature/pr-branch");
+ });
+
+ test("shows fetch warning banner when PR branch has no remote", async ({
+ testPage,
+ apiClient,
+ seedData,
+ backend,
+ }) => {
+ test.setTimeout(90_000);
+
+ const { execSync } = await import("child_process");
+ const fs = await import("fs");
+ const gitEnv = {
+ ...process.env,
+ HOME: backend.tmpDir,
+ GIT_AUTHOR_NAME: "E2E Test",
+ GIT_AUTHOR_EMAIL: "e2e@test.local",
+ GIT_COMMITTER_NAME: "E2E Test",
+ GIT_COMMITTER_EMAIL: "e2e@test.local",
+ };
+
+ // Create a repo with the PR branch locally but NO remote.
+ // This causes fetchBranchToLocal to fail the fetch and fall back to local,
+ // which produces a warning that should be displayed in the UI.
+ const repoDir = `${backend.tmpDir}/repos/e2e-warning-repo`;
+ fs.mkdirSync(repoDir, { recursive: true });
+ execSync("git init -b main", { cwd: repoDir, env: gitEnv });
+ execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
+ execSync("git checkout -b feature/warn-branch", { cwd: repoDir, env: gitEnv });
+ execSync('git commit --allow-empty -m "feature commit"', { cwd: repoDir, env: gitEnv });
+ execSync("git checkout main", { cwd: repoDir, env: gitEnv });
+
+ await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
+ name: "warn-owner/warn-repo",
+ provider: "github",
+ provider_owner: "warn-owner",
+ provider_name: "warn-repo",
+ });
+
+ await apiClient.mockGitHubAddBranches("warn-owner", "warn-repo", [
+ { name: "main" },
+ { name: "feature/warn-branch" },
+ ]);
+
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 200,
+ title: "Warning PR",
+ state: "open",
+ head_branch: "feature/warn-branch",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "warn-owner",
+ repo_name: "warn-repo",
+ },
+ ]);
+
+ // Need worktree executor to trigger fetchBranchToLocal (local executor doesn't produce warnings)
+ const { executors } = await apiClient.listExecutors();
+ const worktreeExec = executors.find((e) => e.type === "worktree");
+ const worktreeProfile = worktreeExec?.profiles?.[0];
+ if (!worktreeProfile) {
+ test.skip(true, "No worktree executor profile available");
+ return;
+ }
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ await kanban.createTaskButton.first().click();
+ const dialog = testPage.getByTestId("create-task-dialog");
+ await expect(dialog).toBeVisible();
+
+ // Enter PR URL
+ await testPage.getByTestId("toggle-github-url").click();
+ await testPage
+ .getByTestId("github-url-input")
+ .fill("https://github.com/warn-owner/warn-repo/pull/200");
+
+ await expect(dialog.getByText("feature/warn-branch")).toBeVisible({ timeout: 10_000 });
+
+ await testPage.getByTestId("task-title-input").fill("Warning Test Task");
+ await testPage.getByTestId("task-description-input").fill("/e2e:simple-message");
+
+ const startBtn = testPage.getByTestId("submit-start-agent");
+ await expect(startBtn).toBeEnabled({ timeout: 15_000 });
+
+ // Select worktree executor
+ const executorSelector = testPage.getByTestId("executor-profile-selector");
+ await executorSelector.click();
+ await testPage.getByRole("option", { name: /Worktree/i }).click();
+
+ await startBtn.click();
+ await expect(dialog).not.toBeVisible({ timeout: 10_000 });
+
+ const card = kanban.taskCardByTitle("Warning Test Task");
+ await expect(card).toBeVisible({ timeout: 10_000 });
+
+ await card.click();
+ await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ // Wait for agent to complete (preparation is done by this point)
+ await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
+ timeout: 30_000,
+ });
+
+ // The warning banner should persist after preparation completes.
+ // It shows because fetchBranchToLocal failed to reach origin (no remote)
+ // and fell back to the local branch.
+ const warningBanner = testPage.getByTestId("prepare-warning-banner");
+ await expect(warningBanner).toBeVisible({ timeout: 10_000 });
+ await expect(warningBanner).toContainText("Could not fetch latest from origin");
+
+ // The "Details" toggle should be visible and expand raw git output on click.
+ const detailsBtn = warningBanner.getByRole("button", { name: "Details" });
+ await expect(detailsBtn).toBeVisible();
+ await detailsBtn.click();
+ // After expanding, raw git output should appear (e.g. "fatal:" from the failed fetch)
+ await expect(warningBanner.locator("pre")).toBeVisible();
+
+ // Clicking again should collapse the details.
+ await detailsBtn.click();
+ await expect(warningBanner.locator("pre")).not.toBeVisible();
+
+ // Dismissing the banner hides it entirely.
+ const dismissBtn = warningBanner.getByRole("button", { name: "Dismiss warning" });
+ await dismissBtn.click();
+ await expect(warningBanner).not.toBeVisible();
+ });
+
test("can toggle between GitHub URL and repository selector", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs
index 1d18cf0ac1..b3b5cdf8c6 100644
--- a/apps/web/eslint.config.mjs
+++ b/apps/web/eslint.config.mjs
@@ -14,6 +14,9 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
+ // Test artifacts (Playwright):
+ "**/test-results/**",
+ "**/playwright-report/**",
]),
{
plugins: {
diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts
index 0a8df2555f..ed0684013b 100644
--- a/apps/web/lib/state/slices/session-runtime/types.ts
+++ b/apps/web/lib/state/slices/session-runtime/types.ts
@@ -147,6 +147,8 @@ export type PrepareStepInfo = {
status: string;
output?: string;
error?: string;
+ warning?: string;
+ warningDetail?: string;
};
export type SessionPrepareState = {
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 25a92cc308..a078f9fe95 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -286,6 +286,8 @@ export type PrepareProgressPayload = {
status: string;
output?: string;
error?: string;
+ warning?: string;
+ warning_detail?: string;
timestamp: string;
};
@@ -297,6 +299,14 @@ export type PrepareCompletedPayload = {
error_message?: string;
duration_ms: number;
workspace_path?: string;
+ steps?: Array<{
+ name: string;
+ status: string;
+ output?: string;
+ error?: string;
+ warning?: string;
+ warning_detail?: string;
+ }>;
timestamp: string;
};
diff --git a/apps/web/lib/ws/handlers/executor-prepare.ts b/apps/web/lib/ws/handlers/executor-prepare.ts
index 0d70b55896..2f25534148 100644
--- a/apps/web/lib/ws/handlers/executor-prepare.ts
+++ b/apps/web/lib/ws/handlers/executor-prepare.ts
@@ -29,22 +29,39 @@ export function registerExecutorPrepareHandlers(store: StoreApi): WsHa
},
"executor.prepare.completed": (message) => {
const payload = message.payload as PrepareCompletedPayload;
- store.setState((state) => ({
- ...state,
- prepareProgress: {
- ...state.prepareProgress,
- bySessionId: {
- ...state.prepareProgress.bySessionId,
- [payload.session_id]: {
- ...state.prepareProgress.bySessionId[payload.session_id],
- sessionId: payload.session_id,
- status: payload.success ? "completed" : "failed",
- errorMessage: payload.error_message,
- durationMs: payload.duration_ms,
+ store.setState((state) => {
+ const existing = state.prepareProgress.bySessionId[payload.session_id];
+ // Use steps from the completed payload if available (ensures warnings
+ // are present even if progress events were missed due to late subscription).
+ const steps = payload.steps?.length
+ ? payload.steps.map((s) => ({
+ name: s.name,
+ status: s.status,
+ output: s.output,
+ error: s.error,
+ warning: s.warning,
+ warningDetail: s.warning_detail,
+ }))
+ : existing?.steps;
+
+ return {
+ ...state,
+ prepareProgress: {
+ ...state.prepareProgress,
+ bySessionId: {
+ ...state.prepareProgress.bySessionId,
+ [payload.session_id]: {
+ ...existing,
+ sessionId: payload.session_id,
+ status: payload.success ? "completed" : "failed",
+ steps: steps ?? [],
+ errorMessage: payload.error_message,
+ durationMs: payload.duration_ms,
+ },
},
},
- },
- }));
+ };
+ });
},
};
}
@@ -63,6 +80,8 @@ function updateSteps(
status: payload.status,
output: payload.output,
error: payload.error,
+ warning: payload.warning,
+ warningDetail: payload.warning_detail,
};
return steps;
}