fix(orchestrator): do not misclassify branch-checked-out-elsewhere as a missing branch (#2782)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
WaleWangPW
2026-08-19 06:25:15 +08:00
committed by GitHub
parent 14797c2901
commit e88e7af0fb
4 changed files with 296 additions and 2 deletions
@@ -87,7 +87,14 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
if req.WorkspacePath == "" && req.RepositoryPath == "" {
completeStepError(&step, "no workspace or repository path provided")
steps = append(steps, step)
return &EnvPrepareResult{Success: false, Steps: steps, ErrorMessage: step.Error, Duration: time.Since(start)}, fmt.Errorf("no workspace path")
prepErr := fmt.Errorf("no workspace path")
return &EnvPrepareResult{
Success: false,
Steps: steps,
ErrorMessage: step.Error,
Duration: time.Since(start),
Error: prepErr,
}, prepErr
}
completeStepSuccess(&step)
steps = append(steps, step)
@@ -124,7 +131,14 @@ func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest, onP
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)
prepErr := fmt.Errorf("checkout branch: %w", err)
return &EnvPrepareResult{
Success: false,
Steps: steps,
ErrorMessage: errMsg,
Duration: time.Since(start),
Error: prepErr,
}, prepErr
}
step.Output = output
completeStepSuccess(&step)
@@ -2,6 +2,9 @@ package lifecycle
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"os"
"os/exec"
"path/filepath"
@@ -9,6 +12,7 @@ import (
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/worktree"
)
func newTestLocalLogger() *logger.Logger {
@@ -456,3 +460,150 @@ func TestLocalPreparer_RunsSetupScriptWithoutCheckout(t *testing.T) {
t.Fatalf("local preparer modified branch: was %q, now %q", startBranch, got)
}
}
// TestLocalPreparer_BranchCheckedOutElsewhereFailsClosed is the regression for
// the bug where a second task's environment creation fails when its target
// branch is already checked out in another worktree AND the remote no longer
// carries that ref. The local preparer must fail closed with a typed
// ErrBranchCheckedOut error so the orchestrator does not misclassify the
// failure as a missing-branch fetch failure and post PR-recovery guidance for
// a branch that is, in fact, present locally.
//
// Before the fix, checkoutBranch concatenated the fetch stderr ("couldn't find
// remote ref") with the checkout stderr ("is already checked out at ..."); the
// combined message made the orchestrator's substring-based
// isMissingBranchError match the "couldn't find remote ref" fragment and emit
// branch_fetch_failed guidance — wrong because the local branch exists, just
// not in this working tree.
//
// The shared checkout's HEAD and index must also be untouched: switching to
// the target branch was never possible here, and partial state changes (e.g.
// the fetch updating FETCH_HEAD or the index) would corrupt another task's
// workspace.
func TestLocalPreparer_BranchCheckedOutElsewhereFailsClosed(t *testing.T) {
isolateGitEnv(t)
repoDir := initGitRepo(t)
env := newIsolatedGitEnv()
for _, args := range [][]string{
{"checkout", "-b", "feature/shared"},
{"commit", "--allow-empty", "-m", "shared work"},
{"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)
}
}
// Use a sibling git worktree so the real git binary (used for setup)
// knows the branch is checked out elsewhere. This mirrors what the user
// hit: another task's worktree holds the branch.
sibling := filepath.Join(t.TempDir(), "sibling")
siblingCmd := exec.Command("git", "worktree", "add", sibling, "feature/shared")
siblingCmd.Dir = repoDir
siblingCmd.Env = env
if out, err := siblingCmd.CombinedOutput(); err != nil {
t.Fatalf("git worktree add failed: %s", out)
}
t.Cleanup(func() {
cleanupCmd := exec.Command("git", "worktree", "remove", "--force", sibling)
cleanupCmd.Dir = repoDir
cleanupCmd.Env = env
_ = cleanupCmd.Run()
})
// Snapshot HEAD + index before the failing prepare so we can prove the
// shared checkout was untouched.
headBefore := readHeadSHA(t, repoDir)
indexBefore := readIndexSHA(t, repoDir)
branchBefore := currentBranch(t, repoDir)
// Fake only fetch: it supplies the missing-remote-ref text that triggers
// the substring match, then lets the real checkout detect the sibling
// worktree and emit its "already checked out at" diagnostic. Pass through
// everything else (symbolic-ref, rev-parse, ls-files, worktree, etc.) by
// exec'ing the real git at its absolute path — re-resolving via PATH would
// loop back to this fake.
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("locate real git: %v", err)
}
fakeBin := t.TempDir()
fakeGit := filepath.Join(fakeBin, "git")
script := `#!/bin/sh
case "$1" in
fetch)
echo "fatal: couldn't find remote ref feature/shared" >&2
exit 128
;;
esac
exec "` + realGit + `" "$@"
`
if err := os.WriteFile(fakeGit, []byte(script), 0o755); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("PATH", fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"))
result, err := NewLocalPreparer(newTestLocalLogger()).Prepare(context.Background(), &EnvPrepareRequest{
TaskID: "task-2",
RepositoryPath: repoDir,
BaseBranch: "feature/shared",
}, nil)
if err == nil {
t.Fatal("expected Prepare() to fail when branch is checked out elsewhere")
}
if result.Success {
t.Fatalf("expected result.Success = false; error=%v", err)
}
if !errors.Is(err, worktree.ErrBranchCheckedOut) {
t.Fatalf("expected error to wrap worktree.ErrBranchCheckedOut, got %v", err)
}
// Combined fetch+checkout stderr must remain visible (the operator still
// needs to see what happened) — this is the same string content that
// previously fooled isMissingBranchError into a false match. The typed
// sentinel is what prevents the misclassification.
if !strings.Contains(err.Error(), "couldn't find remote ref") {
t.Fatalf("expected fetch stderr to be preserved in error chain, got %v", err)
}
if !strings.Contains(err.Error(), "already checked out at") &&
!strings.Contains(err.Error(), "already used by worktree at") {
t.Fatalf("expected real checkout stderr to be preserved in error chain, got %v", err)
}
if got := currentBranch(t, repoDir); got != branchBefore {
t.Fatalf("local preparer changed branch: was %q, now %q", branchBefore, got)
}
if got := readHeadSHA(t, repoDir); got != headBefore {
t.Fatalf("local preparer changed HEAD: was %s, now %s", headBefore, got)
}
if got := readIndexSHA(t, repoDir); got != indexBefore {
t.Fatalf("local preparer changed index: was %s, now %s", indexBefore, got)
}
}
func readHeadSHA(t *testing.T, repoDir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
t.Fatalf("git rev-parse HEAD failed: %v", err)
}
return strings.TrimSpace(string(out))
}
func readIndexSHA(t *testing.T, repoDir string) string {
t.Helper()
cmd := exec.Command("git", "ls-files", "--stage")
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
t.Fatalf("git ls-files failed: %v", err)
}
hash := sha1.New()
hash.Write(out)
return hex.EncodeToString(hash.Sum(nil))
}
@@ -40,6 +40,7 @@ import (
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow/engine"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -2603,6 +2604,15 @@ func canResumeRunning(running *models.ExecutorRunning) bool {
}
func isMissingBranchError(err error) bool {
// A checked-out-elsewhere error chain can still mention "couldn't find
// remote ref" inside its concatenated fetch+checkout stderr, so the
// substring match below would misclassify it as a missing branch and
// post PR-recovery guidance for a branch that is in fact present
// locally. The local preparer wraps worktree.ErrBranchCheckedOut for
// that case; honor the typed sentinel first.
if errors.Is(err, worktree.ErrBranchCheckedOut) {
return false
}
for current := err; current != nil; current = errors.Unwrap(current) {
if isMissingBranchMessage(current.Error()) {
return true
@@ -3,12 +3,14 @@ package orchestrator
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -664,3 +666,120 @@ func TestHandleSessionLaunchFailed_IgnoresPathspecAfterFetchFailure(t *testing.T
t.Fatalf("expected no guidance message after fetch failure, got %d", len(mc.sessionMessages))
}
}
// branchCheckedOutElsewhereCombinedError reproduces the error chain that the
// local environment preparer produces when a target branch is already checked
// out in another worktree and the remote no longer carries the ref. Both
// stderr fragments end up concatenated in a single error string:
//
// - "fetch branch failed: ... couldn't find remote ref ..." (from the fetch
// step)
// - "checkout branch failed: ... is already checked out at ..." (from the
// checkout step that ClassifyGitError maps to ErrBranchCheckedOut)
//
// The wrapping layers are "checkout branch: <classified>" and finally
// "environment preparation failed: <err>" added by the launch manager.
func branchCheckedOutElsewhereCombinedError(branch, siblingPath string) error {
combined := fmt.Sprintf(
"fetch branch failed: exit status 128: fatal: couldn't find remote ref %s\n"+
"checkout branch failed: fatal: '%s' is already checked out at '%s'",
branch, branch, siblingPath,
)
return fmt.Errorf("environment preparation failed: checkout branch: %w: %s",
fmt.Errorf("%w: %s", worktree.ErrBranchCheckedOut, combined),
combined,
)
}
// TestIsMissingBranchError_DoesNotMatchCheckedOutElsewhere is the
// orchestrator-level regression for the local-preparer misclassification.
// The combined fetch+checkout error contains "couldn't find remote ref"
// inside its message, but the chain wraps worktree.ErrBranchCheckedOut —
// isMissingBranchError must not misclassify it as a missing-branch failure.
func TestIsMissingBranchError_DoesNotMatchCheckedOutElsewhere(t *testing.T) {
err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling")
if !errors.Is(err, worktree.ErrBranchCheckedOut) {
t.Fatalf("expected chain to wrap worktree.ErrBranchCheckedOut, got %v", err)
}
if !strings.Contains(err.Error(), "couldn't find remote ref") {
t.Fatalf("expected combined message to contain fetch stderr, got %v", err)
}
if isMissingBranchError(err) {
t.Fatalf("isMissingBranchError misclassified the checked-out-elsewhere error as a missing branch: %v", err)
}
}
// TestExtractMissingBranchName_ExtractsCombinedDiagnostic documents that the
// extractor still returns the branch from a combined fetch and checkout
// diagnostic. The caller must classify the typed sentinel before using it.
func TestExtractMissingBranchName_ExtractsCombinedDiagnostic(t *testing.T) {
err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling")
if got := extractMissingBranchName(err); got != "feature/shared" {
t.Fatalf("extractMissingBranchName() = %q, want %q", got, "feature/shared")
}
}
// TestHandleSessionLaunchFailed_BranchCheckedOutElsewhereDoesNotCreateFetchGuidance
// is the end-to-end orchestrator regression. When the launch manager surfaces
// a checked-out-elsewhere error, the orchestrator must not post a
// branch_fetch_failed / missing-branch guidance message — the branch is not
// missing, only checked out in another worktree.
func TestHandleSessionLaunchFailed_BranchCheckedOutElsewhereDoesNotCreateFetchGuidance(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.SetGitHubService(&mockGitHubService{})
err := branchCheckedOutElsewhereCombinedError("feature/shared", "/tmp/sibling")
svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err)
if len(mc.sessionMessages) != 0 {
var kinds []string
for _, m := range mc.sessionMessages {
if k, ok := m.metadata["failure_kind"].(string); ok {
kinds = append(kinds, k)
}
}
t.Fatalf("expected no session guidance for checked-out-elsewhere error, got %d messages (failure_kinds=%v)",
len(mc.sessionMessages), kinds)
}
if _, ok := svc.suppressToast.Load("session1"); ok {
t.Fatal("expected no missing-branch toast suppression for a checked-out-elsewhere error")
}
}
// TestHandleSessionLaunchFailed_StillCreatesFetchGuidanceForRealMissingBranch
// pins the positive direction: a genuine missing-branch error (no
// ErrBranchCheckedOut in the chain) must still produce the
// branch_fetch_failed guidance. The orchestrator fix must not over-correct.
func TestHandleSessionLaunchFailed_StillCreatesFetchGuidanceForRealMissingBranch(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.SetGitHubService(&mockGitHubService{})
// Plain "couldn't find remote ref" without any ErrBranchCheckedOut — the
// remote branch really is gone and the local branch does not exist
// either, so the guidance must still fire.
err := errors.New("environment preparation failed: branch \"feature/deleted\" not found locally or on remote: fatal: couldn't find remote ref feature/deleted")
if !isMissingBranchError(err) {
t.Fatalf("expected genuine missing-branch error to still be classified, got %v", err)
}
svc.handleSessionLaunchFailed(ctx, "task1", "session1", "repo-a", err)
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 guidance message for genuine missing branch, got %d", len(mc.sessionMessages))
}
if kind, ok := mc.sessionMessages[0].metadata["failure_kind"].(string); !ok || kind != "branch_fetch_failed" {
t.Fatalf("expected failure_kind=branch_fetch_failed, got %#v", mc.sessionMessages[0].metadata["failure_kind"])
}
}