fix: recover failed task launches safely (#3437)

This commit is contained in:
Carlos Florêncio
2026-09-07 11:19:09 +01:00
committed by GitHub
parent 3c6af41063
commit 3fba828e2f
69 changed files with 2280 additions and 277 deletions
@@ -2,6 +2,7 @@ package routingerr
import (
"regexp"
"strings"
"unicode/utf8"
)
@@ -40,20 +41,67 @@ var redactions = []redaction{
{regexp.MustCompile(`/home/[^/\s]+/`), "/home/<redacted>/"},
}
// Redact applies the same credential and home-path rules as Sanitize but
// performs no truncation, so a caller that needs to bound the result by a
// different rule (e.g. keeping the newest bytes of a longer budget cut) sees
// every credential intact and redacted rather than racing a head-truncation
// that runs before it can see the whole input. Redact is idempotent:
// applying it twice equals applying it once.
var (
localUnixPathPattern = regexp.MustCompile(`/(?:[^/\s"']+/)+[^\r\n\s"'<>]+`)
localWindowsPathPattern = regexp.MustCompile(`(?i)[A-Z]:[\\/][^\r\n"']+`)
)
func redactLocalPaths(s string) string {
s = localWindowsPathPattern.ReplaceAllStringFunc(s, func(path string) string {
if len(path) >= 4 && path[2:4] == "//" {
return path
}
return "[path-redacted]"
})
matches := localUnixPathPattern.FindAllStringIndex(s, -1)
if len(matches) == 0 {
return s
}
var redacted strings.Builder
redacted.Grow(len(s))
last := 0
for _, match := range matches {
start, end := match[0], match[1]
redacted.WriteString(s[last:start])
path := s[start:end]
if start >= 2 && s[start-2:start] == ":/" {
redacted.WriteString(path)
last = end
continue
}
redacted.WriteString(redactUnixPath(path))
last = end
}
redacted.WriteString(s[last:])
return redacted.String()
}
func redactUnixPath(path string) string {
if strings.Contains(path, "[path-redacted]") {
return path
}
switch {
case strings.HasPrefix(path, "/Users/"):
return "/Users/<redacted>/[path-redacted]"
case strings.HasPrefix(path, "/home/"):
return "/home/<redacted>/[path-redacted]"
default:
return "[path-redacted]"
}
}
// Redact applies the credential and local-path rules without truncation.
// The function is idempotent: applying it twice equals applying it once.
func Redact(s string) string {
for _, r := range redactions {
s = r.pattern.ReplaceAllString(s, r.replace)
}
s = redactLocalPaths(s)
return s
}
// Sanitize redacts likely credentials, normalizes home paths, and truncates
// Sanitize redacts likely credentials and local paths, then truncates
// to MaxRawExcerptBytes on a rune boundary so a multi-byte character (e.g.
// Vietnamese, CJK) is never split into invalid UTF-8. The function is
// idempotent: applying it twice equals applying it once.
@@ -69,6 +69,30 @@ func TestSanitize_RedactionsGolden(t *testing.T) {
mustNotHave: []string{"/home/bob/"},
mustHave: []string{"/home/<redacted>/"},
},
{
name: "temporary workspace path",
in: "file at /tmp/kandev/task/repo/main.go failed",
mustNotHave: []string{"/tmp/kandev/task/repo/main.go"},
mustHave: []string{"[path-redacted]"},
},
{
name: "workspace root path",
in: "checkout failed in /workspace/kandev/repo/main.go",
mustNotHave: []string{"/workspace/kandev/repo/main.go"},
mustHave: []string{"[path-redacted]"},
},
{
name: "path redaction does not consume a following endpoint",
in: "cannot read /workspace/.env while connecting to https://endpoint.example.test",
mustNotHave: []string{"/workspace/.env"},
mustHave: []string{"https://endpoint.example.test"},
},
{
name: "windows workspace path",
in: `checkout failed in C:\Users\alice\workspace\repo\main.go`,
mustNotHave: []string{`C:\Users\alice\workspace\repo\main.go`},
mustHave: []string{"[path-redacted]"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -96,6 +120,7 @@ func TestSanitize_Idempotent(t *testing.T) {
"--api-key=ABCDEFGHIJKLMNOPQRSTUV --rest",
"password: hunter2 token: foobar secret=abc",
"/Users/me/projects/x /home/me/x",
"/tmp/kandev/repo/main.go C:\\workspace\\repo\\main.go",
}
for _, in := range inputs {
first := Sanitize(in)
@@ -23,6 +23,15 @@ func TestClassifyLaunchFailureUsesTypedBaseBranchCategory(t *testing.T) {
}
}
func TestClassifyLaunchFailureUsesWorkspaceCheckoutCategory(t *testing.T) {
classification := classifyLaunchFailure(errors.Join(
errors.New("PR ref could not be materialized"), worktree.ErrWorkspaceCheckoutFailed,
))
if classification.code != models.LaunchErrorCategoryWorkspaceCheckoutFailed {
t.Fatalf("classification code = %q, want %q", classification.code, models.LaunchErrorCategoryWorkspaceCheckoutFailed)
}
}
func TestTransitionLaunchFailurePersistsTypedErrorAndExactTaskRepository(t *testing.T) {
repo := newMockRepository()
repo.sessions["session-1"] = &models.TaskSession{
@@ -58,21 +67,21 @@ func TestTransitionLaunchFailurePersistsTypedErrorAndExactTaskRepository(t *test
}
}
func TestLaunchFailureReviewActionRequiresSuccessfulEligibilityResolver(t *testing.T) {
func TestGenericLaunchFailureAlwaysOffersRetryLaunch(t *testing.T) {
exec := &Executor{}
exec.launchFailureReviewEligibility = func(context.Context, string) (bool, error) {
return true, nil
}
errorValue := exec.buildLastAgentError(context.Background(), "task-1", "", errors.New("start failed"))
if len(errorValue.RecoveryActions) != 1 || errorValue.RecoveryActions[0] != models.RecoveryActionMarkReviewDone {
t.Fatalf("eligible recovery actions = %#v, want mark_review_done", errorValue.RecoveryActions)
if len(errorValue.RecoveryActions) != 1 || errorValue.RecoveryActions[0] != models.RecoveryActionRetryLaunch {
t.Fatalf("eligible recovery actions = %#v, want retry_launch", errorValue.RecoveryActions)
}
exec.launchFailureReviewEligibility = func(context.Context, string) (bool, error) {
return false, errors.New("lookup failed")
}
errorValue = exec.buildLastAgentError(context.Background(), "task-1", "", errors.New("start failed"))
if len(errorValue.RecoveryActions) != 0 {
if len(errorValue.RecoveryActions) != 1 || errorValue.RecoveryActions[0] != models.RecoveryActionRetryLaunch {
t.Fatalf("failed eligibility lookup exposed recovery actions = %#v", errorValue.RecoveryActions)
}
}
@@ -20,6 +20,11 @@ type launchFailureClassification struct {
func classifyLaunchFailure(err error) launchFailureClassification {
switch {
case errors.Is(err, worktree.ErrWorkspaceCheckoutFailed):
return launchFailureClassification{
code: models.LaunchErrorCategoryWorkspaceCheckoutFailed,
message: "The workspace could not be prepared for this launch.",
}
case errors.Is(err, worktree.ErrInvalidBaseBranch):
return launchFailureClassification{
code: models.LaunchErrorCategoryBaseBranchMissing,
@@ -38,20 +43,27 @@ func classifyLaunchFailure(err error) launchFailureClassification {
}
}
func launchFailureRecoveryActions(taskRepositoryID string, markReviewDone bool) []string {
func launchFailureRecoveryActions(category, taskRepositoryID string, markReviewDone bool) []string {
actions := make([]string, 0, 3)
if strings.TrimSpace(taskRepositoryID) != "" {
actions = append(actions,
models.RecoveryActionRetryDefault,
models.RecoveryActionPickBaseBranch,
)
switch category {
case models.LaunchErrorCategoryBaseBranchMissing:
actions = append(actions,
models.RecoveryActionRetryDefault,
models.RecoveryActionPickBaseBranch,
)
case models.LaunchErrorCategoryDefaultBranchUnresolved:
actions = append(actions, models.RecoveryActionPickBaseBranch)
}
}
if markReviewDone {
if category == models.LaunchErrorCategoryWorkspaceCheckoutFailed || category == models.LaunchErrorCategoryGenericLaunchFailure {
actions = append(actions, models.RecoveryActionRetryLaunch)
}
if category == models.LaunchErrorCategoryPRAlreadyClosed && markReviewDone {
actions = append(actions, models.RecoveryActionMarkReviewDone)
}
return models.NormalizeRecoveryActions(actions)
return models.NormalizeRecoveryActionsForCategory(category, actions)
}
func (e *Executor) buildLastAgentError(
ctx context.Context,
taskID, taskRepositoryID string,
@@ -80,7 +92,7 @@ func (e *Executor) buildLastAgentError(
OccurredAt: occurredAt,
Code: classification.code,
Details: details,
RecoveryActions: launchFailureRecoveryActions(taskRepositoryID, markReviewDone),
RecoveryActions: launchFailureRecoveryActions(classification.code, taskRepositoryID, markReviewDone),
TaskRepositoryID: taskRepositoryID,
StampValue: models.StableLaunchErrorStamp(
taskID, classification.code, taskRepositoryID, occurredAt.Format(time.RFC3339Nano),
@@ -312,7 +312,7 @@ func (h *Handlers) wsRecoverTaskLaunch(ctx context.Context, msg *ws.Message) (*w
if req.ErrorStamp == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "error_stamp is required", nil)
}
if req.Action != "mark_review_done" && req.TaskRepositoryID == "" {
if req.Action != "mark_review_done" && req.Action != "retry_launch" && req.TaskRepositoryID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_repository_id is required for branch recovery", nil)
}
if req.Action == "pick_base_branch" && req.BaseBranch == "" {
@@ -27,6 +27,7 @@ const (
taskLaunchRecoveryRetryDefault = models.RecoveryActionRetryDefault
taskLaunchRecoveryPickBranch = models.RecoveryActionPickBaseBranch
taskLaunchRecoveryMarkDone = models.RecoveryActionMarkReviewDone
taskLaunchRecoveryRetryLaunch = models.RecoveryActionRetryLaunch
)
// TaskLaunchRecoveryRequest is the server-side shape of task.launch.recover.
@@ -118,6 +119,11 @@ func (s *Service) RecoverTaskLaunch(ctx context.Context, req *TaskLaunchRecovery
if err != nil {
return nil, err
}
case taskLaunchRecoveryRetryLaunch:
responseSessionID, err = s.relaunchAndClearTaskLaunchRecovery(ctx, req, source)
if err != nil {
return nil, err
}
case taskLaunchRecoveryMarkDone:
if err := s.markTaskLaunchReviewDone(ctx, req, source); err != nil {
return nil, err
@@ -159,11 +165,11 @@ func (s *Service) validateTaskLaunchRecoveryRequest(req *TaskLaunchRecoveryReque
return ErrTaskLaunchRecoveryInvalid
}
switch req.Action {
case taskLaunchRecoveryRetryDefault, taskLaunchRecoveryPickBranch, taskLaunchRecoveryMarkDone:
case taskLaunchRecoveryRetryDefault, taskLaunchRecoveryPickBranch, taskLaunchRecoveryRetryLaunch, taskLaunchRecoveryMarkDone:
default:
return fmt.Errorf("unsupported task launch recovery action %q", req.Action)
}
if req.Action != taskLaunchRecoveryMarkDone && strings.TrimSpace(req.TaskRepositoryID) == "" {
if (req.Action == taskLaunchRecoveryRetryDefault || req.Action == taskLaunchRecoveryPickBranch) && strings.TrimSpace(req.TaskRepositoryID) == "" {
return fmt.Errorf("task_repository_id is required for %s", req.Action)
}
if req.Action == taskLaunchRecoveryPickBranch && strings.TrimSpace(req.BaseBranch) == "" {
@@ -195,11 +201,13 @@ func (s *Service) loadTaskLaunchRecoverySource(ctx context.Context, req *TaskLau
return nil, err
}
}
if req.Action != taskLaunchRecoveryMarkDone {
if req.Action == taskLaunchRecoveryRetryDefault || req.Action == taskLaunchRecoveryPickBranch {
activeRepositoryID := source.taskRepositoryID()
if activeRepositoryID == "" || activeRepositoryID != req.TaskRepositoryID {
return nil, fmt.Errorf("task_repository_id does not match the active launch error")
}
} else if req.Action == taskLaunchRecoveryRetryLaunch && req.TaskRepositoryID != "" && source.taskRepositoryID() != req.TaskRepositoryID {
return nil, fmt.Errorf("task_repository_id does not match the active launch error")
}
return source, nil
}
@@ -250,6 +258,7 @@ func (s *Service) loadAuthoritativeTaskLaunchRecoveryError(
Stamp: lastError.Stamp(),
OccurredAt: lastError.OccurredAt,
Preview: lastError.Message,
Details: lastError.Details,
Category: lastError.Code,
RecoveryActions: lastError.RecoveryActions,
},
@@ -261,6 +270,7 @@ func (s *Service) loadAuthoritativeTaskLaunchRecoveryError(
Stamp: launchError.Stamp(),
OccurredAt: launchError.OccurredAt,
Preview: launchError.Message,
Details: launchError.Details,
Category: launchError.Code,
RecoveryActions: launchError.RecoveryActions,
}
@@ -59,6 +59,18 @@ type blockingTaskLaunchRecoveryWorktree struct {
err error
}
func TestValidateTaskLaunchRecoveryRequestAllowsRetryLaunchWithoutRepository(t *testing.T) {
svc := &Service{}
err := svc.validateTaskLaunchRecoveryRequest(&TaskLaunchRecoveryRequest{
TaskID: "task-1",
Action: models.RecoveryActionRetryLaunch,
ErrorStamp: "error-1",
})
if err != nil {
t.Fatalf("retry_launch validation error = %v", err)
}
}
func (f blockingTaskLaunchRecoveryWorktree) ResolveRemoteDefaultBranch(context.Context, string) (string, error) {
f.started <- struct{}{}
<-f.release
@@ -69,6 +81,13 @@ func seedTaskLaunchRecoveryFixture(t *testing.T, repo *sqliterepo.Repository, ta
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
category := models.LaunchErrorCategoryGenericLaunchFailure
switch action {
case models.RecoveryActionRetryDefault, models.RecoveryActionPickBaseBranch:
category = models.LaunchErrorCategoryBaseBranchMissing
case models.RecoveryActionMarkReviewDone:
category = models.LaunchErrorCategoryPRAlreadyClosed
}
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-recovery", Name: "Recovery", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
@@ -81,7 +100,7 @@ func seedTaskLaunchRecoveryFixture(t *testing.T, repo *sqliterepo.Repository, ta
models.MetaKeyLastLaunchError: models.TaskLaunchError{
Message: "launch failed",
OccurredAt: now,
Code: models.LaunchErrorCategoryGenericLaunchFailure,
Code: category,
RecoveryActions: []string{action},
TaskRepositoryID: taskRepositoryID,
StampValue: "recovery-stamp",
+1 -1
View File
@@ -524,7 +524,7 @@ func (c *Cloner) refreshWorkspaceRepository(
return err
}
if prNumber > 0 {
prRefspec := fmt.Sprintf("pull/%d/head:refs/remotes/origin/pr/%d", prNumber, prNumber)
prRefspec := fmt.Sprintf("pull/%d/head:refs/kandev/pull/%d/head", prNumber, prNumber)
if err := runFetch(prRefspec); err != nil {
return fmt.Errorf("refresh scoped workspace pull request: %w", err)
}
@@ -174,7 +174,7 @@ helper=${GIT_CONFIG_VALUE_1#!}
t.Fatal(err)
}
if got := string(args); !strings.Contains(got, "fetch\n--prune\n--force\n--no-tags\norigin\n") ||
!strings.Contains(got, "pull/42/head:refs/remotes/origin/pr/42\n") || strings.Contains(got, "--all") {
!strings.Contains(got, "pull/42/head:refs/kandev/pull/42/head\n") || strings.Contains(got, "--all") {
t.Fatalf("refresh args = %q", got)
}
scope, err := os.ReadFile(capturePath + ".scope")
@@ -20,6 +20,7 @@ const (
LaunchErrorCategoryBaseBranchMissing = "base_branch_missing"
LaunchErrorCategoryPRAlreadyClosed = "pr_already_closed"
LaunchErrorCategoryDefaultBranchUnresolved = "default_branch_unresolved"
LaunchErrorCategoryWorkspaceCheckoutFailed = "workspace_checkout_failed"
LaunchErrorCategoryGenericLaunchFailure = "generic_launch_failure"
)
@@ -28,6 +29,7 @@ const (
RecoveryActionRetryDefault = "retry_default"
RecoveryActionPickBaseBranch = "pick_base_branch"
RecoveryActionMarkReviewDone = "mark_review_done"
RecoveryActionRetryLaunch = "retry_launch"
)
const (
@@ -74,9 +76,42 @@ func NormalizeRecoveryActions(actions []string) []string {
return result
}
// NormalizeRecoveryActionsForCategory retains only the actions that are
// meaningful for a typed launch-failure category. Unknown categories keep the
// legacy generic normalization so unrelated runtime errors remain compatible.
func NormalizeRecoveryActionsForCategory(category string, actions []string) []string {
normalized := NormalizeRecoveryActions(actions)
var allowed map[string]struct{}
switch category {
case LaunchErrorCategoryBaseBranchMissing:
allowed = map[string]struct{}{
RecoveryActionRetryDefault: {}, RecoveryActionPickBaseBranch: {},
}
case LaunchErrorCategoryDefaultBranchUnresolved:
allowed = map[string]struct{}{RecoveryActionPickBaseBranch: {}}
case LaunchErrorCategoryWorkspaceCheckoutFailed, LaunchErrorCategoryGenericLaunchFailure:
if len(normalized) == 0 {
return nil
}
return []string{RecoveryActionRetryLaunch}
case LaunchErrorCategoryPRAlreadyClosed:
allowed = map[string]struct{}{RecoveryActionMarkReviewDone: {}}
default:
return normalized
}
result := make([]string, 0, len(normalized))
for _, action := range normalized {
if _, ok := allowed[action]; ok {
result = append(result, action)
}
}
return result
}
func isKnownRecoveryAction(action string) bool {
switch action {
case RecoveryActionRetryDefault, RecoveryActionPickBaseBranch, RecoveryActionMarkReviewDone:
case RecoveryActionRetryDefault, RecoveryActionPickBaseBranch, RecoveryActionMarkReviewDone, RecoveryActionRetryLaunch:
return true
default:
return false
@@ -85,7 +120,8 @@ func isKnownRecoveryAction(action string) bool {
func normalizeLastAgentError(value LastAgentError) LastAgentError {
value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes)
value.RecoveryActions = NormalizeRecoveryActions(value.RecoveryActions)
value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes)
value.RecoveryActions = NormalizeRecoveryActionsForCategory(value.Code, value.RecoveryActions)
value.TaskRepositoryID = truncateUTF8Bytes(value.TaskRepositoryID, maxTaskRepositoryIDBytes)
value.StampValue = boundedLaunchErrorStamp(value.StampValue)
value.Details = truncateUTF8Bytes(value.Details, maxLaunchErrorDetailsBytes)
@@ -94,10 +130,10 @@ func normalizeLastAgentError(value LastAgentError) LastAgentError {
func normalizeTaskLaunchError(value TaskLaunchError) TaskLaunchError {
value.Message = truncateUTF8Bytes(value.Message, maxLaunchErrorMessageBytes)
value.RecoveryActions = NormalizeRecoveryActions(value.RecoveryActions)
value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes)
value.RecoveryActions = NormalizeRecoveryActionsForCategory(value.Code, value.RecoveryActions)
value.TaskRepositoryID = truncateUTF8Bytes(value.TaskRepositoryID, maxTaskRepositoryIDBytes)
value.StampValue = boundedLaunchErrorStamp(value.StampValue)
value.Code = truncateUTF8Bytes(value.Code, maxLaunchErrorCategoryBytes)
value.Details = truncateUTF8Bytes(value.Details, maxLaunchErrorDetailsBytes)
return value
}
@@ -30,6 +30,46 @@ func TestLoadLastAgentErrorUsesExplicitStampAndNormalizesRecoveryFields(t *testi
}, lastError.RecoveryActions)
}
func TestNormalizeRecoveryActionsForCategoryFiltersActionsByLaunchCause(t *testing.T) {
tests := []struct {
name string
category string
input []string
want []string
}{
{
name: "generic launch failure",
category: LaunchErrorCategoryGenericLaunchFailure,
input: []string{RecoveryActionRetryDefault, RecoveryActionPickBaseBranch, RecoveryActionMarkReviewDone},
want: []string{RecoveryActionRetryLaunch},
},
{
name: "workspace checkout failure",
category: LaunchErrorCategoryWorkspaceCheckoutFailed,
input: []string{RecoveryActionRetryDefault, RecoveryActionRetryLaunch},
want: []string{RecoveryActionRetryLaunch},
},
{
name: "missing base branch",
category: LaunchErrorCategoryBaseBranchMissing,
input: []string{RecoveryActionRetryLaunch, RecoveryActionRetryDefault, RecoveryActionPickBaseBranch},
want: []string{RecoveryActionRetryDefault, RecoveryActionPickBaseBranch},
},
{
name: "closed pull request",
category: LaunchErrorCategoryPRAlreadyClosed,
input: []string{RecoveryActionRetryLaunch, RecoveryActionMarkReviewDone},
want: []string{RecoveryActionMarkReviewDone},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t, test.want, NormalizeRecoveryActionsForCategory(test.category, test.input))
})
}
}
func TestTaskLaunchErrorStoreIsNoOpForSameStamp(t *testing.T) {
firstTime := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
secondTime := firstTime.Add(time.Minute)
@@ -23,6 +23,7 @@ const (
// MaxActiveErrorPreviewBytes keeps an error decoration safe to send with
// every task row without turning it into a message-stream transport.
MaxActiveErrorPreviewBytes = 512
MaxActiveErrorDetailsBytes = 4096
maxSessionIDBytes = 256
maxTaskRepositoryIDBytes = 256
maxPendingActionBytes = 128
@@ -64,6 +65,7 @@ type ActiveErrorSummary struct {
Stamp string `json:"stamp"`
OccurredAt time.Time `json:"occurred_at"`
Preview string `json:"preview"`
Details string `json:"details,omitempty"`
Category string `json:"category,omitempty"`
RecoveryActions []string `json:"recovery_actions,omitempty"`
}
@@ -156,6 +158,7 @@ func validateActiveError(activeError *ActiveErrorSummary) error {
{"active error task repository id", activeError.TaskRepositoryID, maxTaskRepositoryIDBytes},
{"active error stamp", activeError.Stamp, maxActiveErrorStampBytes},
{"active error preview", activeError.Preview, MaxActiveErrorPreviewBytes},
{"active error details", activeError.Details, MaxActiveErrorDetailsBytes},
{"active error category", activeError.Category, maxActiveErrorCategoryBytes},
}
for _, field := range fields {
@@ -166,7 +169,7 @@ func validateActiveError(activeError *ActiveErrorSummary) error {
if len(activeError.RecoveryActions) > 3 {
return fmt.Errorf("active error has more than three recovery actions")
}
if !slices.Equal(activeError.RecoveryActions, models.NormalizeRecoveryActions(activeError.RecoveryActions)) {
if !slices.Equal(activeError.RecoveryActions, models.NormalizeRecoveryActionsForCategory(activeError.Category, activeError.RecoveryActions)) {
return fmt.Errorf("active error has unknown or duplicate recovery actions")
}
return nil
@@ -171,7 +171,7 @@ func TestTaskStatusSummaryProjectsTaskOwnedErrorFields(t *testing.T) {
if got.ActiveError.Category != "pr_already_closed" {
t.Fatalf("active error category = %q", got.ActiveError.Category)
}
wantActions := []string{"mark_review_done", "retry_default"}
wantActions := []string{"mark_review_done"}
if !reflect.DeepEqual(got.ActiveError.RecoveryActions, wantActions) {
t.Fatalf("active error actions = %#v, want %#v", got.ActiveError.RecoveryActions, wantActions)
}
@@ -332,7 +332,7 @@ func applySummaryBaseline(state *projectionState, summary *TaskStatusSummary) {
}
if summary.ActiveError != nil {
copy := *summary.ActiveError
copy.RecoveryActions = normalizeRecoveryActions(copy.RecoveryActions)
copy.RecoveryActions = normalizeRecoveryActionsForCategory(copy.Category, copy.RecoveryActions)
if copy.SessionID != "" {
state.errors[copy.SessionID] = &copy
} else {
@@ -170,18 +170,21 @@ func errorFromMap(now time.Time, sessionID string, data map[string]interface{})
occurredAt = now.UTC()
}
preview := truncateString(message, MaxActiveErrorPreviewBytes)
details := truncateString(stringField(data, "details"), MaxActiveErrorDetailsBytes)
stamp := stringField(data, "stamp")
if stamp == "" {
stamp = occurredAt.UTC().Format(time.RFC3339Nano) + ":" + preview
}
category := truncateString(firstString(data, "category", "code"), maxActiveErrorCategoryBytes)
return &ActiveErrorSummary{
SessionID: truncateString(sessionID, maxSessionIDBytes),
TaskRepositoryID: truncateString(stringField(data, "task_repository_id"), maxTaskRepositoryIDBytes),
Stamp: truncateString(stamp, maxActiveErrorStampBytes),
OccurredAt: occurredAt.UTC(),
Preview: preview,
Category: truncateString(firstString(data, "category", "code"), maxActiveErrorCategoryBytes),
RecoveryActions: normalizeRecoveryActions(recoveryActionsValue(data["recovery_actions"])),
Details: details,
Category: category,
RecoveryActions: normalizeRecoveryActionsForCategory(category, recoveryActionsValue(data["recovery_actions"])),
}, true
}
@@ -191,7 +194,7 @@ func errorEqual(a, b *ActiveErrorSummary) bool {
}
return a.SessionID == b.SessionID && a.TaskRepositoryID == b.TaskRepositoryID &&
a.Stamp == b.Stamp && a.OccurredAt.Equal(b.OccurredAt) &&
a.Preview == b.Preview && a.Category == b.Category &&
a.Preview == b.Preview && a.Details == b.Details && a.Category == b.Category &&
slices.Equal(a.RecoveryActions, b.RecoveryActions)
}
@@ -199,6 +202,10 @@ func normalizeRecoveryActions(actions []string) []string {
return models.NormalizeRecoveryActions(actions)
}
func normalizeRecoveryActionsForCategory(category string, actions []string) []string {
return models.NormalizeRecoveryActionsForCategory(category, actions)
}
func recoveryActionsValue(value interface{}) []string {
switch actions := value.(type) {
case []string:
@@ -140,8 +140,9 @@ func normalizeRebuildError(input *ActiveErrorSummary, now time.Time) *ActiveErro
copy.Preview = truncateString(copy.Preview, MaxActiveErrorPreviewBytes)
copy.SessionID = truncateString(copy.SessionID, maxSessionIDBytes)
copy.TaskRepositoryID = truncateString(copy.TaskRepositoryID, maxTaskRepositoryIDBytes)
copy.Details = truncateString(copy.Details, MaxActiveErrorDetailsBytes)
copy.Category = truncateString(copy.Category, maxActiveErrorCategoryBytes)
copy.RecoveryActions = normalizeRecoveryActions(copy.RecoveryActions)
copy.RecoveryActions = normalizeRecoveryActionsForCategory(copy.Category, copy.RecoveryActions)
if copy.Stamp == "" {
copy.Stamp = copy.OccurredAt.UTC().Format(time.RFC3339Nano) + ":" + copy.Preview
}
+4
View File
@@ -57,6 +57,10 @@ var (
// ErrNonFastForward is returned when a fetch/pull is rejected due to non-fast-forward updates.
ErrNonFastForward = errors.New("non-fast-forward update rejected")
// ErrWorkspaceCheckoutFailed is returned when a launch cannot materialize
// the requested checkout ref without risking an existing local branch.
ErrWorkspaceCheckoutFailed = errors.New("workspace checkout failed")
// ErrGitCryptFailed is returned when git-crypt unlock fails during worktree creation.
ErrGitCryptFailed = errors.New("git-crypt unlock failed")
@@ -259,7 +259,7 @@ func TestCreateWorktree_ManagedRefreshRunsOnlyForMaterialization(t *testing.T) {
func TestCreateWorktree_ManagedRefreshUsesRefreshedPRHead(t *testing.T) {
repoPath, wantSHA := initGitRepoWithPullRef(t, 974, "feature/fork-pr")
runGit(t, repoPath, "fetch", "origin", "pull/974/head:refs/remotes/origin/pr/974")
runGit(t, repoPath, "fetch", "origin", "pull/974/head:"+pullRequestSnapshotRef(974))
runGit(t, repoPath, "remote", "set-url", "origin", "https://127.0.0.1:1/never.git")
mgr, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger())
@@ -445,9 +445,9 @@ func advanceRemoteBranch(t *testing.T, repoPath, branch string) string {
func initManagedPRCheckoutBranch(t *testing.T, prNumber int, branch string) (string, string) {
t.Helper()
repoPath, _ := initGitRepoWithPullRef(t, prNumber, branch)
prRef := fmt.Sprintf("pull/%d/head:refs/remotes/origin/pr/%d", prNumber, prNumber)
prRef := fmt.Sprintf("pull/%d/head:%s", prNumber, pullRequestSnapshotRef(prNumber))
runGit(t, repoPath, "fetch", "origin", prRef)
runGit(t, repoPath, "branch", branch, fmt.Sprintf("origin/pr/%d", prNumber))
runGit(t, repoPath, "branch", branch, pullRequestSnapshotRef(prNumber))
originURL := strings.TrimSpace(runGit(t, repoPath, "remote", "get-url", "origin"))
remoteClone := filepath.Join(t.TempDir(), "remote-pr-clone")
runGit(t, filepath.Dir(remoteClone), "clone", originURL, remoteClone)
@@ -455,7 +455,7 @@ func initManagedPRCheckoutBranch(t *testing.T, prNumber int, branch string) (str
runGit(t, remoteClone, "config", "user.name", "Remote User")
runGit(t, remoteClone, "config", "commit.gpgsign", "false")
runGit(t, remoteClone, "fetch", "origin", prRef)
runGit(t, remoteClone, "checkout", "-b", branch, fmt.Sprintf("origin/pr/%d", prNumber))
runGit(t, remoteClone, "checkout", "-b", branch, pullRequestSnapshotRef(prNumber))
writeRepoFile(t, remoteClone, "remote-pr-refresh.txt", "refreshed PR\n")
runGit(t, remoteClone, "add", "remote-pr-refresh.txt")
runGit(t, remoteClone, "commit", "-m", "refreshed PR commit")
+18 -7
View File
@@ -15,6 +15,13 @@ import (
"github.com/kandev/kandev/internal/common/subproc"
)
// pullRequestSnapshotRef is internal state owned by Kandev. Keeping PR heads
// outside refs/remotes/origin prevents ordinary remote pruning or a user branch
// named pr/<N> from changing the commit selected for a task launch.
func pullRequestSnapshotRef(prNumber int) string {
return fmt.Sprintf("refs/kandev/pull/%d/head", prNumber)
}
// isGitRepo checks if a path is a Git repository.
func (m *Manager) isGitRepo(path string) bool {
gitDir := filepath.Join(path, ".git")
@@ -212,18 +219,16 @@ func (m *Manager) prepareCheckoutFromRefreshedOrigin(ctx context.Context, repoPa
}
// prepareBranchFromRefreshedOrigin selects a provider-refreshed source branch
// without contacting origin. A PR number selects the dedicated origin/pr/<N>
// ref, which is also how fork PR heads are kept available after the
// authenticated refresh. When both refs exist, the selected ref is the one
// that contains the other. A local-only ref is preserved, a refreshed remote
// ref is returned to the caller as the worktree start point, and divergence or
// an unverified relationship fails closed.
// without contacting origin. A PR number selects the dedicated Kandev-owned
// snapshot ref, which is also how fork PR heads are kept available after the
// authenticated refresh. PR preparation never creates or resets a local
// branch with the PR's source name.
func (m *Manager) prepareBranchFromRefreshedOrigin(
ctx context.Context, repoPath, localBranch, sourceBranch string, prNumber int,
) (string, error) {
remoteRef := "origin/" + sourceBranch
if prNumber > 0 {
remoteRef = fmt.Sprintf("origin/pr/%d", prNumber)
remoteRef = pullRequestSnapshotRef(prNumber)
}
localExists, err := m.branchExists(ctx, repoPath, localBranch)
if err != nil {
@@ -233,6 +238,12 @@ func (m *Manager) prepareBranchFromRefreshedOrigin(
if err != nil {
return "", err
}
if prNumber > 0 {
if !remoteExists {
return "", fmt.Errorf("required fetched remote ref %q is missing: %w", remoteRef, ErrWorkspaceCheckoutFailed)
}
return remoteRef, nil
}
if !localExists && !remoteExists {
return "", nil
}
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -988,13 +989,36 @@ func (m *Manager) validateTaskDir(taskDir string) error {
// addWorktreeForBranch creates the git worktree, trying the checkout branch directly first
// and falling back to a suffixed branch if the checkout branch is already in use.
// When a checkout branch is specified, it sets the upstream tracking branch to
// origin/<checkout-branch> so ahead/behind counts are relative to the PR's remote branch.
// origin/<checkout-branch> when that ref points to the worktree's start point.
func (m *Manager) addWorktreeForBranch(ctx context.Context, req CreateRequest, worktreePath, fallbackBranch, startPoint, baseRef string) (string, string, error) {
if req.CheckoutBranch == "" {
id, err := m.gitAddWorktree(ctx, req.RepositoryPath, fallbackBranch, worktreePath, baseRef)
return id, fallbackBranch, err
}
// A pull request head is an immutable remote ref for this launch. Never
// update or reset a local branch with the same name as the PR head: that
// branch may belong to another task and may contain unpushed work.
if req.PRNumber > 0 {
branchName := req.CheckoutBranch
exists, probeErr := m.branchExists(ctx, req.RepositoryPath, branchName)
if probeErr != nil {
return "", "", fmt.Errorf("verify PR checkout branch %q: %w: %w", branchName, ErrWorkspaceCheckoutFailed, probeErr)
}
if exists {
return m.addPRWorktreeWithUniqueBranch(ctx, req, worktreePath, startPoint)
}
id, addErr := m.gitAddWorktree(ctx, req.RepositoryPath, branchName, worktreePath, startPoint)
if addErr == nil {
m.setUpstreamIfMatchesStartPoint(ctx, worktreePath, branchName, req.CheckoutBranch, startPoint)
return id, branchName, nil
}
if !errors.Is(addErr, ErrBranchExists) {
return "", "", fmt.Errorf("materialize PR checkout branch %q: %w: %w", branchName, ErrWorkspaceCheckoutFailed, addErr)
}
return m.addPRWorktreeWithUniqueBranch(ctx, req, worktreePath, startPoint)
}
// Try checking out the PR branch directly (common case: single task per PR).
var id string
var err error
@@ -1021,6 +1045,51 @@ func (m *Manager) addWorktreeForBranch(ctx context.Context, req CreateRequest, w
return id, suffixed, err
}
const prCheckoutBranchAttempts = 20
func prCheckoutBranchCandidates(checkoutBranch, taskID string) []string {
suffix := TaskDirSuffix(taskID)
if suffix == "" {
suffix = SmallSuffix(3)
}
candidates := make([]string, 0, prCheckoutBranchAttempts)
for attempt := 0; attempt < prCheckoutBranchAttempts; attempt++ {
candidate := checkoutBranch + "-" + suffix
if attempt > 0 {
candidate += "-" + strconv.Itoa(attempt)
}
candidates = append(candidates, candidate)
}
return candidates
}
func (m *Manager) addPRWorktreeWithUniqueBranch(ctx context.Context, req CreateRequest, worktreePath, startPoint string) (string, string, error) {
for _, branchName := range prCheckoutBranchCandidates(req.CheckoutBranch, req.TaskID) {
exists, probeErr := m.branchExists(ctx, req.RepositoryPath, branchName)
if probeErr != nil {
return "", "", fmt.Errorf("verify PR checkout branch %q: %w: %w", branchName, ErrWorkspaceCheckoutFailed, probeErr)
}
if exists {
continue
}
id, addErr := m.gitAddWorktree(ctx, req.RepositoryPath, branchName, worktreePath, startPoint)
if addErr == nil {
m.setUpstreamIfMatchesStartPoint(ctx, worktreePath, branchName, req.CheckoutBranch, startPoint)
return id, branchName, nil
}
if !errors.Is(addErr, ErrBranchExists) {
return "", "", fmt.Errorf("materialize PR checkout branch %q: %w: %w", branchName, ErrWorkspaceCheckoutFailed, addErr)
}
}
return "", "", fmt.Errorf(
"materialize PR checkout branch %q after %d candidates: %w: %w",
req.CheckoutBranch,
prCheckoutBranchAttempts,
ErrWorkspaceCheckoutFailed,
ErrBranchExists,
)
}
// setUpstreamIfExists sets the upstream tracking branch for a worktree branch
// to origin/<remoteBranch> if the remote-tracking ref exists. Non-fatal on failure.
func (m *Manager) setUpstreamIfExists(ctx context.Context, worktreePath, localBranch, remoteBranch string) {
@@ -1041,6 +1110,25 @@ func (m *Manager) setUpstreamIfExists(ctx context.Context, worktreePath, localBr
}
}
func (m *Manager) setUpstreamIfMatchesStartPoint(
ctx context.Context,
worktreePath, localBranch, remoteBranch, startPoint string,
) {
upstream := "origin/" + remoteBranch
upstreamOID, upstreamErr := m.refCommitOID(ctx, worktreePath, upstream)
startOID, startErr := m.refCommitOID(ctx, worktreePath, startPoint)
if upstreamErr != nil || startErr != nil || upstreamOID != startOID {
return
}
m.setUpstreamIfExists(ctx, worktreePath, localBranch, remoteBranch)
}
func (m *Manager) refCommitOID(ctx context.Context, worktreePath, ref string) (string, error) {
cmd := m.newNonInteractiveGitCmd(ctx, worktreePath, "rev-parse", "--verify", ref+"^{commit}")
output, err := runGitCmdCombinedOutput(ctx, cmd)
return strings.TrimSpace(string(output)), err
}
// FetchBranchResult holds the outcome of a fetchBranchToLocal call.
type FetchBranchResult struct {
StartPoint string // Ref to use as worktree start point (e.g., "origin/branch"); empty = use local branch
@@ -1078,10 +1166,35 @@ func (m *Manager) fetchBranchToLocalWithPolicy(
// the manager_git.go probes fixed in PR #1216 (70s lock-held trace,
// signal:killed). With this ordering the budget only counts actual
// git execution time.
refspec := branch + ":" + branch
if prNumber > 0 {
refspec = fmt.Sprintf("pull/%d/head:%s", prNumber, branch)
remoteRef := pullRequestSnapshotRef(prNumber)
refspec := fmt.Sprintf("+refs/pull/%d/head:%s", prNumber, remoteRef)
output, err, fetchCtxErr := m.runGitCombinedAfterAcquire(ctx, m.fetchTimeout, repoPath, "fetch", gitNoTags, "origin", refspec)
if err != nil {
outputStr := string(output)
if isRemoteBranchMissingError(outputStr) || isRemoteRefMissingError(errors.New(outputStr)) {
return nil, fmt.Errorf(
"fetch pull request head %d found no remote ref: %w: %w",
prNumber, ErrWorkspaceCheckoutFailed, newConfirmedRemoteRefMissingError(outputStr),
)
}
reason := classifyGitFallbackReason(err, outputStr, fetchCtxErr)
return nil, fmt.Errorf(
"fetch pull request head %d failed (%s): %w: %w",
prNumber, reason, ErrWorkspaceCheckoutFailed, syncFailureCause(reason, err, fetchCtxErr),
)
}
exists, verifyErr := m.branchExists(ctx, repoPath, remoteRef)
if verifyErr != nil {
return nil, fmt.Errorf("verify fetched pull request head %d: %w: %w", prNumber, ErrWorkspaceCheckoutFailed, verifyErr)
}
if !exists {
return nil, fmt.Errorf("verify fetched pull request head %d: %w", prNumber, ErrWorkspaceCheckoutFailed)
}
return &FetchBranchResult{StartPoint: remoteRef}, nil
}
refspec := branch + ":" + branch
output, err, fetchCtxErr := m.runGitCombinedAfterAcquire(ctx, m.fetchTimeout, repoPath, "fetch", gitNoTags, "origin", refspec)
if err == nil {
return &FetchBranchResult{}, nil
@@ -1991,10 +2104,11 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe
return nil, fmt.Errorf("restore empty-remote worktree branch: %s: %w", strings.TrimSpace(string(output)), branchErr)
}
} else {
if _, fetchErr := m.fetchBranchToLocalWithPolicy(
fetchResult, fetchErr := m.fetchBranchToLocalWithPolicy(
ctx, req.RepositoryPath, existing.Branch, req.PRNumber,
req.PullBeforeWorktree && !req.RemoteSyncHandled,
); fetchErr != nil {
)
if fetchErr != nil {
m.logger.Warn("failed to restore worktree branch during recreate",
zap.String("worktree_id", existing.ID),
zap.String("branch", existing.Branch),
@@ -2011,6 +2125,13 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe
}
return nil, fetchErr
}
if fetchResult != nil && fetchResult.StartPoint != "" {
branchCmd := m.newNonInteractiveGitCmd(ctx, req.RepositoryPath, "branch", existing.Branch, fetchResult.StartPoint)
if output, branchErr := runGitCmdCombinedOutput(ctx, branchCmd); branchErr != nil {
return nil, fmt.Errorf("restore worktree branch from refreshed ref: %s: %w", strings.TrimSpace(string(output)), branchErr)
}
exists = true
}
}
}
if exists && req.RemoteContribution != nil {
@@ -0,0 +1,74 @@
package worktree
import (
"context"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCreateWorktreePRHeadCollisionPreservesLocalBranch(t *testing.T) {
const branch = "feature/unit-7-reject-leg-wa-waj"
repoPath, firstPRHeadSHA := initGitRepoWithPullRef(t, 3294, branch)
runGit(t, repoPath, "fetch", "origin", pullHeadRef(3294)+":"+pullRequestSnapshotRef(3294))
runGit(t, repoPath, "branch", branch, pullRequestSnapshotRef(3294))
originURL := strings.TrimSpace(runGit(t, repoPath, "remote", "get-url", "origin"))
remoteClone := filepath.Join(t.TempDir(), "pr-3408-clone")
runGit(t, filepath.Dir(remoteClone), "clone", originURL, remoteClone)
runGit(t, remoteClone, "config", "user.email", "test@example.com")
runGit(t, remoteClone, "config", "user.name", "Test User")
runGit(t, remoteClone, "config", "commit.gpgsign", "false")
runGit(t, remoteClone, "checkout", "-b", "tmp-pr-head-3408", "main")
writeTestFile(t, filepath.Join(remoteClone, "pr-3408.txt"), "second PR head\n")
runGit(t, remoteClone, "add", "pr-3408.txt")
runGit(t, remoteClone, "commit", "-m", "second PR head")
secondPRHeadSHA := strings.TrimSpace(runGit(t, remoteClone, "rev-parse", "HEAD"))
runGit(t, remoteClone, "push", "origin", "HEAD:"+pullHeadRef(3408))
runGit(t, remoteClone, "push", "origin", "main:refs/heads/"+branch)
runGit(t, repoPath, "fetch", "origin", "refs/heads/"+branch+":refs/remotes/origin/"+branch)
collisionBranch := branch + "-" + TaskDirSuffix("task-pr-3408")
runGit(t, repoPath, "branch", collisionBranch, "main")
mgr, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-pr-3408",
SessionID: "session-pr-3408",
TaskTitle: "PR 3408",
RepositoryID: "repo-1",
RepositoryPath: repoPath,
BaseBranch: "main",
CheckoutBranch: branch,
PRNumber: 3408,
TaskDirName: "task-pr-3408",
RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() should materialize the exact PR head: %v", err)
}
if wt.Branch != collisionBranch+"-1" {
t.Fatalf("PR worktree should retry a colliding task branch, got %q, want %q", wt.Branch, collisionBranch+"-1")
}
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", branch)); got != firstPRHeadSHA {
t.Fatalf("local branch SHA = %q, want preserved SHA %q", got, firstPRHeadSHA)
}
if got := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD")); got != secondPRHeadSHA {
t.Fatalf("worktree HEAD SHA = %q, want exact PR 3408 head %q", got, secondPRHeadSHA)
}
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", pullRequestSnapshotRef(3408))); got != secondPRHeadSHA {
t.Fatalf("stored PR ref SHA = %q, want %q", got, secondPRHeadSHA)
}
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "@{upstream}")
cmd.Dir = wt.Path
if out, err := cmd.CombinedOutput(); err == nil {
t.Fatalf("PR worktree should not track a divergent source branch, got %q", strings.TrimSpace(string(out)))
}
}
@@ -11,10 +11,11 @@ import (
)
// TestFetchBranchToLocal_PRNumberUsesPullRefspec verifies that when a PR
// number is supplied, the manager fetches refs/pull/<N>/head into the local
// branch. This is the fork-PR path: the head branch does not exist on origin
// by name (only as a pull/<N>/head ref), so the previous branch-name fetch
// would fail with "couldn't find remote ref".
// number is supplied, the manager fetches refs/pull/<N>/head into an isolated
// Kandev-owned ref. This is the fork-PR path: the head branch does not
// exist on origin by name (only as a pull/<N>/head ref), so the previous
// branch-name fetch would fail with "couldn't find remote ref" or overwrite a
// local branch with the same name.
func TestFetchBranchToLocal_PRNumberUsesPullRefspec(t *testing.T) {
repoPath, prHeadSHA := initGitRepoWithPullRef(t, 974, "feature/enrich-linear-issue-hap")
@@ -36,9 +37,66 @@ func TestFetchBranchToLocal_PRNumberUsesPullRefspec(t *testing.T) {
t.Fatalf("expected no warning, got %q", result.Warning)
}
gotSHA := strings.TrimSpace(runGit(t, repoPath, "rev-parse", "feature/enrich-linear-issue-hap"))
if result.StartPoint != pullRequestSnapshotRef(974) {
t.Fatalf("start point = %q, want %q", result.StartPoint, pullRequestSnapshotRef(974))
}
gotSHA := strings.TrimSpace(runGit(t, repoPath, "rev-parse", pullRequestSnapshotRef(974)))
if gotSHA != prHeadSHA {
t.Fatalf("local branch SHA = %q, want %q (PR head)", gotSHA, prHeadSHA)
t.Fatalf("isolated PR ref SHA = %q, want %q (PR head)", gotSHA, prHeadSHA)
}
}
func TestFetchBranchToLocal_PRNumberSnapshotSurvivesPruneAndOriginPRBranch(t *testing.T) {
const prNumber = 976
const headBranch = "feature/pr-snapshot"
repoPath, prHeadSHA := initGitRepoWithPullRef(t, prNumber, headBranch)
mgr, err := NewManager(newTestConfig(t), newMockStore(), newTestLogger())
if err != nil {
t.Fatalf("NewManager failed: %v", err)
}
result, err := mgr.fetchBranchToLocal(context.Background(), repoPath, headBranch, prNumber)
if err != nil {
t.Fatalf("fetchBranchToLocal(pr=%d) unexpected error: %v", prNumber, err)
}
const expectedRef = "refs/kandev/pull/976/head"
if result.StartPoint != expectedRef {
t.Fatalf("start point = %q, want %q", result.StartPoint, expectedRef)
}
originURL := strings.TrimSpace(runGit(t, repoPath, "remote", "get-url", "origin"))
remoteClone := filepath.Join(t.TempDir(), "origin-pr-branch")
runGit(t, filepath.Dir(remoteClone), "clone", originURL, remoteClone)
runGit(t, remoteClone, "config", "user.email", "remote@example.com")
runGit(t, remoteClone, "config", "user.name", "Remote User")
runGit(t, remoteClone, "config", "commit.gpgsign", "false")
runGit(t, remoteClone, "checkout", "-b", "pr/976", "main")
writeTestFile(t, filepath.Join(remoteClone, "origin-pr-branch.txt"), "origin branch\n")
runGit(t, remoteClone, "add", "origin-pr-branch.txt")
runGit(t, remoteClone, "commit", "-m", "origin pr branch")
originPRBranchSHA := strings.TrimSpace(runGit(t, remoteClone, "rev-parse", "HEAD"))
runGit(t, remoteClone, "push", "origin", "HEAD:refs/heads/pr/976")
// A routine refresh must not prune or overwrite the Kandev-owned snapshot.
runGit(t, repoPath, "fetch", "--all", "--prune")
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", expectedRef)); got != prHeadSHA {
t.Fatalf("Kandev PR snapshot SHA = %q, want %q", got, prHeadSHA)
}
if got := strings.TrimSpace(runGit(t, repoPath, "rev-parse", "refs/remotes/origin/pr/976")); got != originPRBranchSHA {
t.Fatalf("origin/pr/976 SHA = %q, want real branch SHA %q", got, originPRBranchSHA)
}
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "task-pr-snapshot", SessionID: "session-pr-snapshot", TaskTitle: "PR snapshot",
RepositoryID: "repo-1", RepositoryPath: repoPath, BaseBranch: "main",
CheckoutBranch: headBranch, PRNumber: prNumber, RemoteSyncHandled: true,
TaskDirName: "task-pr-snapshot", RepoName: "repo-1",
})
if err != nil {
t.Fatalf("Create() should use the Kandev snapshot after normal fetch: %v", err)
}
if got := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD")); got != prHeadSHA {
t.Fatalf("worktree HEAD = %q, want PR snapshot %q", got, prHeadSHA)
}
}
@@ -109,14 +167,9 @@ func TestCreateWorktree_PRNumberCreatesWorktreeFromForkRef(t *testing.T) {
}
// TestCreateWorktree_PRNumberSecondWorktreeForSameForkPR exercises the
// retry-path regression flagged in PR #990 review: when a fork PR's head
// branch is already checked out in another worktree, the refspec fetch is
// refused and the manager retries with a bare `pull/<N>/head` fetch. That
// retry updates FETCH_HEAD but does NOT create `origin/<branch>` because
// the branch doesn't exist on origin — so returning `StartPoint:
// "origin/<branch>"` would make the downstream `git worktree add` fail
// with an invalid ref. Empty StartPoint must be returned so the caller
// falls back to the local branch (already at the PR head from task 1).
// PR-head isolation when two worktrees use the same source branch name. Each
// worktree must point at the immutable PR ref while its local branch remains
// unique.
func TestCreateWorktree_PRNumberSecondWorktreeForSameForkPR(t *testing.T) {
repoPath, prHeadSHA := initGitRepoWithPullRef(t, 974, "feature/fork-pr")
@@ -455,7 +455,7 @@ func TestRecreate_ForkPRFetchesPullHeadRef(t *testing.T) {
func TestRecreate_ManagedRefreshUsesRefreshedPRHeadWithoutNetwork(t *testing.T) {
repoPath, wantSHA := initGitRepoWithPullRef(t, 975, "feature/managed-fork-pr")
runGit(t, repoPath, "fetch", "origin", "pull/975/head:refs/remotes/origin/pr/975")
runGit(t, repoPath, "fetch", "origin", "pull/975/head:"+pullRequestSnapshotRef(975))
runGit(t, repoPath, "remote", "set-url", "origin", "https://127.0.0.1:1/never.git")
worktreePath := filepath.Join(t.TempDir(), "task-managed-pr", "repo-1")
if err := os.MkdirAll(worktreePath, 0755); err != nil {
@@ -312,6 +312,8 @@ type ChatInputAreaProps = {
onRequestChangesTooltipDismiss?: () => void;
panelState: ChatPanelState;
isSending: boolean;
/** The task-owned launch card renders recovery for the failed start. */
launchErrorOwned?: boolean;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
/** Hide ACP/session-specific controls (model picker, mode, MCP, reset context,
@@ -428,6 +430,7 @@ export function ChatInputArea({
onScrollToStart,
statusTaskId = null,
showAgentStartHint = false,
launchErrorOwned = false,
}: ChatInputAreaProps) {
const { resolvedSessionId, taskId, isAgentBusy } = panelState;
const statusRowTaskId = resolveStatusRowTaskId(taskId, statusTaskId);
@@ -456,6 +459,7 @@ export function ChatInputArea({
minimalToolbar,
hideAgentControls,
hidePlanMode,
launchErrorOwned,
});
return (
<div
@@ -0,0 +1,112 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputContainer } from "./chat-input-container";
const { containerState } = vi.hoisted(() => ({
containerState: {
showNewSessionDialog: false,
setShowNewSessionDialog: vi.fn(),
contextPopoverOpen: false,
setContextPopoverOpen: vi.fn(),
height: "auto",
containerRef: { current: null },
resizeHandleProps: { onMouseDown: vi.fn(), onDoubleClick: vi.fn() },
value: "",
inputRef: { current: null },
addFiles: vi.fn().mockResolvedValue(undefined),
fileInputRef: { current: null },
handleChange: vi.fn(),
handleSubmitWithReset: vi.fn(),
allItems: [],
hasPendingAttachmentUploads: false,
isInputFocused: false,
setIsInputFocused: vi.fn(),
hasClarification: false,
hasPendingComments: false,
hasContextZone: false,
showFocusHint: false,
inputPlaceholder: "Ask to make changes",
isDisabled: false,
submitDisabled: false,
submitDisabledReason: undefined,
},
}));
vi.mock("./use-chat-input-container", () => ({
useChatInputContainer: () => containerState,
}));
vi.mock("./session-stopped-banner", () => ({
SessionStoppedBanner: () => <div data-testid="session-stopped-banner" />,
}));
vi.mock("./chat-input-body", () => ({
ChatInputBody: () => <div data-testid="chat-input-body" />,
}));
vi.mock("@/hooks/domains/session/use-session-recovery-actions", () => ({
useSessionRecoveryActions: () => ({}),
}));
vi.mock("@/hooks/use-is-utility-configured", () => ({
useIsUtilityConfigured: () => false,
}));
vi.mock("@/hooks/use-utility-agent-generator", () => ({
useUtilityAgentGenerator: () => ({
enhancePrompt: vi.fn(),
isEnhancingPrompt: false,
}),
}));
vi.mock("@/hooks/use-prompt-result-delivery", () => ({
usePromptResultDelivery: () => ({
pendingResult: null,
captureScope: vi.fn(),
deliver: vi.fn(),
applyPending: vi.fn(),
copyPending: vi.fn(),
}),
}));
vi.mock("@/lib/i18n", () => ({
t: (key: string) => key,
}));
const baseProps = {
onSubmit: vi.fn(),
sessionId: "session-1",
taskId: "task-1",
taskDescription: "",
planModeEnabled: false,
onPlanModeChange: vi.fn(),
isAgentBusy: false,
isStarting: false,
isSending: false,
onCancel: vi.fn(),
};
afterEach(() => cleanup());
describe("ChatInputContainer launch-error ownership", () => {
it("hides the editor when the task launch card owns a failed session", () => {
render(<ChatInputContainer {...baseProps} isFailed launchErrorOwned />);
expect(screen.queryByTestId("chat-input-body")).toBeNull();
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
});
it("renders the stopped banner when the failed session has no launch-card owner", () => {
render(<ChatInputContainer {...baseProps} isFailed />);
expect(screen.queryByTestId("chat-input-body")).toBeNull();
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
});
it("keeps the editor visible when the owned launch error is not a failed session", () => {
render(<ChatInputContainer {...baseProps} launchErrorOwned />);
expect(screen.getByTestId("chat-input-body")).toBeTruthy();
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
});
});
@@ -21,6 +21,7 @@ import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
import { PromptResultRecovery } from "@/components/prompt-result-recovery";
import { t } from "@/lib/i18n";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
// Re-export ImageAttachment type for consumers
export type { ImageAttachment } from "./image-attachment-preview";
@@ -96,6 +97,8 @@ type ChatInputContainerProps = {
isCompleted?: boolean;
sessionErrorMessage?: string;
needsRecovery?: boolean;
/** The task-owned launch card renders the failed-start recovery. */
launchErrorOwned?: boolean;
executorUnavailable?: boolean;
executorUnavailableReason?: string;
contextItems?: ContextItem[];
@@ -287,6 +290,13 @@ function useChatPromptEnhancement({
return { handleEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, promptDelivery };
}
function useChatInputRecoveryActions(taskId: string | null, sessionId: string | null) {
return useSessionRecoveryActions({
taskId: taskId ?? "",
sessionId: sessionId ?? "",
});
}
export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInputContainerProps>(
// eslint-disable-next-line complexity -- top-level component chooses the stopped or editor surface after shared hook setup.
function ChatInputContainer(props, ref) {
@@ -322,10 +332,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
onSubmit: props.onSubmit,
});
const recoveryActions = useSessionRecoveryActions({
taskId: taskId ?? "",
sessionId: sessionId ?? "",
});
const recoveryActions = useChatInputRecoveryActions(taskId, sessionId);
const promptEnhancement = useChatPromptEnhancement({
inputRef: s.inputRef,
@@ -335,7 +342,23 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
taskDescription,
});
if (p.isFailed || p.isCompleted || executorUnavailable) {
if (
shouldHideChatInputForLaunchError({
isFailed: p.isFailed,
launchErrorOwned: p.launchErrorOwned,
})
) {
return null;
}
if (
shouldRenderStoppedSessionBanner({
isFailed: p.isFailed,
isCompleted: p.isCompleted,
executorUnavailable,
launchErrorOwned: p.launchErrorOwned,
})
) {
return (
<SessionStoppedBanner
mode={p.isCompleted ? "completed" : "recoverable"}
@@ -15,7 +15,9 @@ vi.mock("@/components/task/chat/message-renderer", () => ({
import { MessageListFooter } from "./message-list-footer";
const AGENT_STATUS_TEST_ID = "agent-status";
const ACTION_MESSAGE_TEST_ID = "action-message";
const SESSION_ID = "session-1";
const FAILED_SESSION_STATE = "FAILED" as const;
afterEach(cleanup);
@@ -45,23 +47,30 @@ const laterFailure = {
},
} satisfies Message;
// eslint-disable-next-line max-lines-per-function -- footer ownership cases share one focused fixture.
describe("MessageListFooter", () => {
it("lets an actionable footer failure own the failure presentation", () => {
render(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
/>,
);
expect(screen.getByTestId("action-message")).toBeTruthy();
expect(screen.getByTestId(ACTION_MESSAGE_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
});
it("keeps the generic status for a failed session without an actionable footer", () => {
render(<MessageListFooter sessionState="FAILED" sessionId={SESSION_ID} messages={[]} />);
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
/>,
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
});
@@ -81,13 +90,17 @@ describe("MessageListFooter", () => {
it("switches ownership when missing-branch recovery arrives after failure", () => {
const { rerender } = render(
<MessageListFooter sessionState="FAILED" sessionId={SESSION_ID} messages={[]} />,
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
/>,
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
rerender(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
@@ -95,13 +108,13 @@ describe("MessageListFooter", () => {
);
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
expect(screen.getByTestId("action-message")).toBeTruthy();
expect(screen.getByTestId(ACTION_MESSAGE_TEST_ID)).toBeTruthy();
});
it("does not restore stale missing-branch recovery after a later failure", () => {
render(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[actionableFailure, laterFailure]}
footerActionMessages={[actionableFailure]}
@@ -109,6 +122,56 @@ describe("MessageListFooter", () => {
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("action-message")).toBeNull();
expect(screen.queryByTestId(ACTION_MESSAGE_TEST_ID)).toBeNull();
});
it("hides launch failure footer surfaces when the task card owns the error", () => {
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
launchErrorOwned
/>,
);
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
expect(screen.queryByTestId(ACTION_MESSAGE_TEST_ID)).toBeNull();
});
it("keeps stale launch failure footer history when the current card owns the error", () => {
const staleFailure = {
...actionableFailure,
id: "failure-stale",
content: "Stale branch recovery",
metadata: {
...actionableFailure.metadata,
launch_error_stamp: "old-stamp",
},
} satisfies Message;
const currentFailure = {
...actionableFailure,
id: "failure-current",
content: "Current branch recovery",
metadata: {
...actionableFailure.metadata,
launch_error_stamp: "current-stamp",
},
} satisfies Message;
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[staleFailure, currentFailure]}
launchErrorOwned
launchErrorStamp="current-stamp"
/>,
);
expect(screen.getByText("Stale branch recovery")).toBeTruthy();
expect(screen.queryByText("Current branch recovery")).toBeNull();
});
});
@@ -3,6 +3,7 @@
import type { Message, TaskSessionState } from "@/lib/types/http";
import { AgentStatus } from "@/components/task/chat/messages/agent-status";
import { MessageRenderer } from "@/components/task/chat/message-renderer";
import { filterLaunchErrorMessages } from "./message-list-shared";
type MessageListFooterProps = {
sessionState?: TaskSessionState;
@@ -10,6 +11,12 @@ type MessageListFooterProps = {
messages: Message[];
isWorking?: boolean;
footerActionMessages?: Message[];
/** The task-owned launch card is rendering the current failure. */
launchErrorOwned?: boolean;
/** Stamp used to keep historical launch-only footer rows visible. */
launchErrorStamp?: string;
/** Timestamp used to identify unstamped synthetic rows from the active failure. */
launchErrorOccurredAt?: string;
};
function isMissingBranchFailure(message: Message): boolean {
@@ -45,18 +52,25 @@ export function MessageListFooter({
messages,
isWorking,
footerActionMessages = [],
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
}: MessageListFooterProps) {
const currentActionableFailure = findCurrentActionableFailure(messages, footerActionMessages);
const recoveryOwnsFailure =
!launchErrorOwned &&
sessionState === "FAILED" &&
currentActionableFailure !== undefined &&
isMissingBranchFailure(currentActionableFailure);
const visibleFooterActionMessages = footerActionMessages.filter(
(message) => !isMissingBranchFailure(message) || message.id === currentActionableFailure?.id,
);
const visibleFooterActionMessages = launchErrorOwned
? filterLaunchErrorMessages(footerActionMessages, true, launchErrorStamp, launchErrorOccurredAt)
: footerActionMessages.filter(
(message) =>
!isMissingBranchFailure(message) || message.id === currentActionableFailure?.id,
);
return (
<>
{!recoveryOwnsFailure && (
{!recoveryOwnsFailure && !(launchErrorOwned && sessionState === "FAILED") && (
<AgentStatus
sessionState={sessionState}
sessionId={sessionId}
@@ -25,6 +25,8 @@ import {
getLastTurnGroupId,
getStreamingAgentMessageId,
canReassertDividerScroll,
filterLaunchErrorItems,
filterLaunchErrorMessages,
resolveLastPromptEdge,
isElementFullyVisible,
} from "./message-list-shared";
@@ -271,6 +273,9 @@ type NativeMessageListBodyProps = {
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
autoScrollEnabled: boolean;
dividerBeforeItemKey?: string | null;
launchErrorOwned: boolean;
launchErrorStamp?: string;
launchErrorOccurredAt?: string;
};
/**
@@ -474,6 +479,9 @@ function NativeMessageListBody({
onScrollToMessage,
autoScrollEnabled,
dividerBeforeItemKey,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
}: NativeMessageListBodyProps) {
return (
<div className="p-4">
@@ -515,6 +523,9 @@ function NativeMessageListBody({
messages={messages}
isWorking={isWorking}
footerActionMessages={footerActionMessages}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
{/* Bottom anchor keeps the view pinned while auto-scroll is enabled.
@@ -558,14 +569,43 @@ export const NativeMessageList = memo(
dividerBeforeItemKey,
anchoredBarHeight,
isVisible = true,
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
}: MessageListProps,
ref,
) {
const scrollRef = useRef<HTMLDivElement>(null);
const visibleItems = useMemo(
() =>
filterLaunchErrorItems(items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt),
[items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleMessages = useMemo(
() =>
filterLaunchErrorMessages(
messages,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[messages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleFooterActionMessages = useMemo(
() =>
filterLaunchErrorMessages(
footerActionMessages ?? [],
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[footerActionMessages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const { isInitialLoading, showLoadingState } = getConversationLoadingState({
messagesLoading,
messagesCount: messages.length,
messagesCount: visibleMessages.length,
isWorking,
sessionState,
});
@@ -574,15 +614,15 @@ export const NativeMessageList = memo(
});
const { activeTurnId } = useSessionTurn(sessionId);
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
const streamingMessageId = getStreamingAgentMessageId(messages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
const streamingMessageId = getStreamingAgentMessageId(visibleMessages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(visibleItems), [visibleItems]);
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } =
useNativeMessageListScroll({
scrollRef,
ref,
items,
messages,
items: visibleItems,
messages: visibleMessages,
isWorking,
sessionId,
enabled: autoScrollEnabled,
@@ -617,9 +657,9 @@ export const NativeMessageList = memo(
>
{stickyPromptBar}
<NativeMessageListBody
items={items}
messages={messages}
footerActionMessages={footerActionMessages}
items={visibleItems}
messages={visibleMessages}
footerActionMessages={visibleFooterActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
@@ -642,6 +682,9 @@ export const NativeMessageList = memo(
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
</SessionPanelContent>
);
@@ -93,6 +93,8 @@ import {
getLastUserMessageId,
getFirstUserMessageId,
isElementFullyVisible,
filterLaunchErrorItems,
filterLaunchErrorMessages,
resolveLastPromptControls,
resolveLastPromptEdge,
shouldAutoScrollToBottom,
@@ -131,6 +133,106 @@ describe("getEffectiveActiveTurnId", () => {
});
});
describe("launch error-owned transcript filtering", () => {
it("removes launch-only messages only when the task card owns the error", () => {
const emptyTurn = {
id: "empty-turn-1",
metadata: { empty_turn: true },
} as unknown as Message;
const runtimeFailure = {
id: "runtime-failure-1",
metadata: { failure_kind: "provider_quota_limited" },
} as unknown as Message;
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], false)).toEqual([
emptyTurn,
runtimeFailure,
]);
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], true)).toEqual([runtimeFailure]);
});
it("removes preparation and previous-agent rows while retaining transcript messages", () => {
const runtimeMessage = { id: "message-1" } as unknown as Message;
const items: RenderItem[] = [
{ type: "prepare_progress", id: "prepare-1", sessionId: "s1" },
{
type: "agent_error_notice",
id: "agent-error-1",
sessionId: "s1",
error: { message: "matching error", stamp: "stamp-1" },
},
{
type: "agent_error_notice",
id: "agent-error-2",
sessionId: "s1",
error: { message: "unrelated error", stamp: "old-stamp" },
},
{
type: "message",
message: {
id: "empty-1",
created_at: "2026-06-14T12:00:01Z",
metadata: { empty_turn: true },
} as unknown as Message,
},
{ type: "message", message: runtimeMessage },
];
expect(filterLaunchErrorItems(items, true, "stamp-1", "2026-06-14T12:00:00Z")).toEqual([
{
type: "agent_error_notice",
id: "agent-error-2",
sessionId: "s1",
error: { message: "unrelated error", stamp: "old-stamp" },
},
{ type: "message", message: runtimeMessage },
]);
});
it("keeps stale launch-only history when the current failure has no carried stamp", () => {
const staleEmptyTurn = {
id: "empty-stale",
created_at: "2026-07-21T00:00:00Z",
metadata: { empty_turn: true },
} as unknown as Message;
const currentEmptyTurn = {
id: "empty-current",
created_at: "2026-07-22T00:00:00Z",
metadata: { empty_turn: true },
} as unknown as Message;
expect(filterLaunchErrorMessages([staleEmptyTurn, currentEmptyTurn], true)).toEqual([
staleEmptyTurn,
]);
expect(
filterLaunchErrorItems(
[
{ type: "message", message: staleEmptyTurn },
{ type: "message", message: currentEmptyTurn },
],
true,
"current-stamp",
"2026-07-22T00:00:00Z",
),
).toEqual([{ type: "message", message: staleEmptyTurn }]);
});
it("filters only launch-only rows carrying the active failure stamp", () => {
const staleMissingBranch = {
id: "missing-stale",
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "old-stamp" },
} as unknown as Message;
const currentMissingBranch = {
id: "missing-current",
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "current-stamp" },
} as unknown as Message;
expect(
filterLaunchErrorMessages([staleMissingBranch, currentMissingBranch], true, "current-stamp"),
).toEqual([staleMissingBranch]);
});
});
describe("canReassertDividerScroll", () => {
it("never applies without a divider target", () => {
expect(
@@ -17,6 +17,7 @@ import {
lastAgentErrorStamp,
readLastAgentError,
} from "@/lib/session-last-agent-error";
import { isLaunchErrorSurfaceMessage } from "./types";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
@@ -67,6 +68,12 @@ export type MessageListProps = {
* remain mounted while inactive and use this transition to recover missed
* oldest-page sentinel observations. */
isVisible?: boolean;
/** The task-owned launch card renders the matching failure. */
launchErrorOwned?: boolean;
/** Stamp used to distinguish a matching last-agent-error notice. */
launchErrorStamp?: string;
/** Timestamp used to identify unstamped synthetic rows from the active failure. */
launchErrorOccurredAt?: string;
};
/** Imperative handle exposed by `MessageList`, letting the chat panel scroll
@@ -91,6 +98,86 @@ export function getItemKey(item: RenderItem): string {
return item.message.id;
}
const LAUNCH_ERROR_STAMP_KEYS = ["launch_error_stamp", "error_stamp"] as const;
function launchErrorSurfaceStamp(message: Message): string | undefined {
const metadata = message.metadata as Record<string, unknown> | undefined;
for (const key of LAUNCH_ERROR_STAMP_KEYS) {
const value = metadata?.[key];
if (typeof value === "string" && value.length > 0) return value;
}
return undefined;
}
function launchErrorSurfaceMessageIds(
messages: Message[],
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): Set<string> {
const launchMessages = messages.filter(isLaunchErrorSurfaceMessage);
if (launchMessages.length === 0) return new Set();
const unstampedMessages = launchMessages.filter(
(message) => launchErrorSurfaceStamp(message) === undefined,
);
const hiddenIds = new Set<string>();
if (launchErrorStamp) {
const stampedMatches = launchMessages.filter(
(message) => launchErrorSurfaceStamp(message) === launchErrorStamp,
);
stampedMatches.forEach((message) => hiddenIds.add(message.id));
}
const occurredAt = launchErrorOccurredAt ? Date.parse(launchErrorOccurredAt) : Number.NaN;
if (!Number.isNaN(occurredAt)) {
unstampedMessages.forEach((message) => {
const createdAt = Date.parse(message.created_at);
if (!Number.isNaN(createdAt) && createdAt >= occurredAt) hiddenIds.add(message.id);
});
}
if (hiddenIds.size > 0 || launchErrorStamp || launchErrorOccurredAt) return hiddenIds;
// Older synthetic rows do not carry the launch stamp. Keep historical rows
// intact and hide only the most recent row when no identity is available.
const fallbackMessage = launchMessages[launchMessages.length - 1];
return fallbackMessage ? new Set([fallbackMessage.id]) : new Set();
}
/** Remove the launch-only transcript decorations once the task card owns them. */
export function filterLaunchErrorMessages(
messages: Message[],
launchErrorOwned: boolean,
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): Message[] {
if (!launchErrorOwned) return messages;
const hiddenIds = launchErrorSurfaceMessageIds(messages, launchErrorStamp, launchErrorOccurredAt);
return messages.filter((message) => !hiddenIds.has(message.id));
}
/** Remove synthetic launch rows while preserving ordinary transcript activity. */
export function filterLaunchErrorItems(
items: RenderItem[],
launchErrorOwned: boolean,
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): RenderItem[] {
if (!launchErrorOwned) return items;
const hiddenMessageIds = launchErrorSurfaceMessageIds(
items.flatMap((item) => (item.type === "message" ? [item.message] : [])),
launchErrorStamp,
launchErrorOccurredAt,
);
return items.filter((item) => {
if (item.type === "prepare_progress") return false;
if (item.type === "agent_error_notice") {
return !launchErrorStamp || lastAgentErrorStamp(item.error) !== launchErrorStamp;
}
return item.type !== "message" || !hiddenMessageIds.has(item.message.id);
});
}
/** The active turn id, but only while the agent is working — turns no longer
* in progress aren't treated as active. */
export function getEffectiveActiveTurnId(
@@ -1,6 +1,7 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { buildStoppedBannerProps } from "./chat-input-container";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
import { useComposerProps } from "./use-composer-props";
function composerArgs(errorMessage: string) {
@@ -51,6 +52,20 @@ function composerArgs(errorMessage: string) {
}
describe("stopped-session error propagation", () => {
it("gives the task launch card ownership of a failed session", () => {
expect(
shouldRenderStoppedSessionBanner({
isFailed: true,
isCompleted: false,
executorUnavailable: false,
launchErrorOwned: true,
}),
).toBe(false);
expect(shouldHideChatInputForLaunchError({ isFailed: true, launchErrorOwned: true })).toBe(
true,
);
});
it("threads the sanitized session error from panel state into composer props", () => {
const errorMessage = "pods is forbidden: RBAC denied the request";
const { result } = renderHook(() => useComposerProps(composerArgs(errorMessage)));
+70 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import { isRichOutputMessage, kandevToolStemOf, type ToolCallMetadata } from "./types";
import {
isLaunchErrorSurfaceMessage,
isMatchingTaskLaunchError,
isTaskLaunchErrorOwnedBySession,
isRichOutputMessage,
kandevToolStemOf,
type ToolCallMetadata,
} from "./types";
function toolCall(rawName: string, field: "tool_name" | "title" | "content" = "title"): Message {
const metadata: Record<string, unknown> = {
@@ -65,3 +72,65 @@ describe("isRichOutputMessage", () => {
);
});
});
describe("task launch error presentation predicates", () => {
it("matches a summary error only by session and stamp", () => {
const activeError = { session_id: "session-1", stamp: "stamp-1" };
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "stamp-1",
}),
).toBe(true);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-2",
errorStamp: "stamp-1",
}),
).toBe(false);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "old-stamp",
}),
).toBe(false);
});
it("matches task-level errors only when the candidate has no session", () => {
const activeError = { stamp: "stamp-1" };
expect(isMatchingTaskLaunchError(activeError, { errorStamp: "stamp-1" })).toBe(true);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "stamp-1",
}),
).toBe(false);
});
it("keeps a task-wide card visible without giving it ownership of a prior session", () => {
const activeError = { stamp: "stamp-1" };
expect(isTaskLaunchErrorOwnedBySession(activeError, null)).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, undefined)).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, "prior-session")).toBe(false);
});
it("gives a session-owned error ownership only of its exact session", () => {
const activeError = { session_id: "session-1", stamp: "stamp-1" };
expect(isTaskLaunchErrorOwnedBySession(activeError, "session-1")).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, null)).toBe(false);
expect(isTaskLaunchErrorOwnedBySession(activeError, "session-2")).toBe(false);
});
it.each([
{ metadata: { empty_turn: true }, expected: true },
{ metadata: { failure_kind: "missing_pr_branch" }, expected: true },
{ metadata: { failure_kind: "provider_quota_limited" }, expected: false },
{ metadata: undefined, expected: false },
])("classifies launch-only message noise", ({ metadata, expected }) => {
expect(isLaunchErrorSurfaceMessage({ id: "message-1", metadata } as never)).toBe(expected);
});
});
+59
View File
@@ -1,6 +1,7 @@
"use client";
import type { Message } from "@/lib/types/http";
import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary";
import { extractKandevStem } from "./messages/kandev/parse";
export type SubagentTaskPayload = {
@@ -187,6 +188,64 @@ export function isSubagentMessage(message: Message): boolean {
return metadata?.normalized?.kind === "subagent_task";
}
export type TaskLaunchErrorIdentity = Pick<TaskStatusSummaryActiveError, "session_id" | "stamp">;
export function shouldRenderStoppedSessionBanner(input: {
isFailed: boolean;
isCompleted: boolean;
executorUnavailable: boolean;
launchErrorOwned?: boolean;
}): boolean {
return (
!input.launchErrorOwned && (input.isFailed || input.isCompleted || input.executorUnavailable)
);
}
export function shouldHideChatInputForLaunchError(input: {
isFailed: boolean;
launchErrorOwned?: boolean;
}): boolean {
return input.launchErrorOwned === true && input.isFailed;
}
/** Matches one rendered error surface to the task-owned launch error. */
export function isMatchingTaskLaunchError(
activeError: TaskLaunchErrorIdentity | null | undefined,
candidate: { sessionId?: string | null; errorStamp?: string | null },
): boolean {
if (!activeError?.stamp || !candidate.errorStamp) return false;
return (
(activeError.session_id ?? null) === (candidate.sessionId ?? null) &&
activeError.stamp === candidate.errorStamp
);
}
/** A task-wide launch error stays visible while any prior session is selected. */
export function isTaskLaunchErrorVisibleForSession(
activeError: TaskLaunchErrorIdentity | null | undefined,
sessionId: string | null | undefined,
): boolean {
if (!activeError?.stamp) return false;
if (sessionId === undefined || activeError.session_id == null) return true;
return activeError.session_id === sessionId;
}
/** Whether the active launch error owns the selected session's surfaces. */
export function isTaskLaunchErrorOwnedBySession(
activeError: TaskLaunchErrorIdentity | null | undefined,
sessionId: string | null | undefined,
): boolean {
if (!activeError?.stamp) return false;
if (activeError.session_id == null) return sessionId == null;
return sessionId != null && activeError.session_id === sessionId;
}
/** Messages that are only useful while a launch failure has no typed owner. */
export function isLaunchErrorSurfaceMessage(message: Message): boolean {
const metadata = message.metadata as Record<string, unknown> | undefined;
return metadata?.empty_turn === true || metadata?.failure_kind === "missing_pr_branch";
}
export type StatusMetadata = {
progress?: number;
status?: string;
@@ -16,6 +16,7 @@ type ComposerPropsArgs = {
handleSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult;
handleCancelTurn: () => Promise<void>;
isSending: boolean;
launchErrorOwned?: boolean;
showRequestChangesTooltip: boolean;
onRequestChangesTooltipDismiss?: () => void;
onClarificationResolved: () => void;
@@ -41,6 +42,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
handleSubmit,
handleCancelTurn,
isSending,
launchErrorOwned,
showRequestChangesTooltip,
onRequestChangesTooltipDismiss,
onClarificationResolved,
@@ -75,6 +77,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
isPreparingEnvironment: panelState.isPreparingEnvironment,
isMoving,
isSending,
launchErrorOwned,
onCancel: handleCancelTurn,
placeholder,
pendingClarification: panelState.pendingClarification,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildRunErrorsFromSessions,
filterVisibleRunErrors,
hasMatchingSessionLaunchError,
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
@@ -137,6 +138,32 @@ describe("hasMatchingSessionLaunchError", () => {
});
});
describe("filterVisibleRunErrors", () => {
it("keeps ordinary provider errors when the active summary is not a launch error", () => {
const errors = buildRunErrorsFromSessions([
session({
metadata: {
last_agent_error: {
message: "The provider rejected the request.",
code: "provider_auth_required",
stamp: "provider-stamp-1",
},
},
}),
]);
expect(
filterVisibleRunErrors(errors, {
session_id: "s1",
stamp: "provider-stamp-1",
occurred_at: "2026-08-02T15:00:01Z",
preview: "The provider rejected the request.",
category: "provider_auth_required",
}),
).toEqual(errors);
});
});
describe("liveSessionMetadataFromStore", () => {
const metadata = { last_agent_error: { message: "boom", remediation_url: URL } };
@@ -2,6 +2,8 @@ import type { CommentTurnContext } from "./turn-context";
import { groupSortKey, type SessionGroup } from "./session-groups";
import { normalizeRemediationUrl } from "@/lib/remediation-url";
import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error";
import { isTypedTaskLaunchError } from "./components/task-launch-error-entry";
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
import type {
RunError,
TaskComment,
@@ -9,6 +11,7 @@ import type {
TaskSession,
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
/**
* Discriminated union of every row that can appear in the task chat
@@ -85,9 +88,25 @@ export function hasMatchingSessionLaunchError(
runErrors: RunError[],
): boolean {
if (!summarySessionId) return false;
return runErrors.some(
return runErrors.some((error) =>
isMatchingTaskLaunchError(
{ session_id: summarySessionId, stamp: summaryStamp ?? "" },
{ sessionId: error.sessionId, errorStamp: error.errorStamp },
),
);
}
export function filterVisibleRunErrors(
runErrors: RunError[],
activeError: TaskStatusSummary["active_error"],
): RunError[] {
if (!isTypedTaskLaunchError(activeError)) return runErrors;
return runErrors.filter(
(error) =>
error.sessionId === summarySessionId && (!summaryStamp || error.errorStamp === summaryStamp),
!isMatchingTaskLaunchError(activeError, {
sessionId: error.sessionId,
errorStamp: error.errorStamp,
}),
);
}
@@ -54,6 +54,7 @@ function TypedRunLaunchErrorEntry({
stamp: error.errorStamp ?? "",
occurred_at: error.failedAt,
preview,
details: error.failureDetails,
category: error.failureCode,
recovery_actions: error.recoveryActions,
}}
@@ -1,17 +1,16 @@
"use client";
import { useMemo } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { RunError } from "@/app/office/tasks/[id]/types";
import { hasMatchingSessionLaunchError } from "../chat-entries";
import { isTaskLaunchErrorVisibleForSession } from "@/components/task/chat/types";
import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry";
type TaskChatLaunchErrorProps = {
taskId: string;
workspaceId: string;
statusSummary?: TaskStatusSummary | null;
runErrors: RunError[];
/** When supplied, only render the error that belongs to this session. */
sessionId?: string | null;
repositories?: TaskRepository[];
};
@@ -19,17 +18,14 @@ export function TaskChatLaunchError({
taskId,
workspaceId,
statusSummary,
runErrors,
sessionId,
repositories,
}: TaskChatLaunchErrorProps) {
const error = useMemo(() => {
const candidate = statusSummary?.active_error;
if (!isTypedTaskLaunchError(candidate)) return null;
if (hasMatchingSessionLaunchError(candidate.session_id, candidate.stamp, runErrors)) {
return null;
}
return candidate;
}, [runErrors, statusSummary]);
const candidate = statusSummary?.active_error;
const error =
isTypedTaskLaunchError(candidate) && isTaskLaunchErrorVisibleForSession(candidate, sessionId)
? candidate
: null;
if (!error) return null;
return (
@@ -43,11 +43,15 @@ vi.mock("@/components/task/task-launch-branch-picker", () => ({
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const ERROR_STAMP = "launch-stamp-1";
const WORKSPACE_ID = "workspace-1";
const OCCURRED_AT = "2026-08-19T10:00:00Z";
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
stamp: ERROR_STAMP,
occurred_at: "2026-08-19T10:00:00Z",
occurred_at: OCCURRED_AT,
preview: "The selected base branch is not available.",
category: "base_branch_missing",
recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"],
@@ -78,6 +82,7 @@ describe("TaskLaunchErrorEntry", () => {
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
])("classifies launch category %s as a typed launch error", (category) => {
expect(
@@ -88,30 +93,121 @@ describe("TaskLaunchErrorEntry", () => {
).toBe(true);
});
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
session_id: "session-1",
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
details: "The pull request head was fetched into its isolated ref.",
category: "workspace_checkout_failed",
recovery_actions: ["retry_launch"],
};
render(
<TaskLaunchErrorEntry taskId={TASK_ID} workspaceId={WORKSPACE_ID} error={checkoutError} />,
);
expect(screen.getByText(checkoutError.preview)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show details/i }));
expect(screen.getByText(checkoutError.details!)).toBeTruthy();
expect(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID)).toBeTruthy();
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
task_id: TASK_ID,
session_id: "session-1",
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
});
it("keeps recovery failures inside the launch card", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
expect(toastMock).not.toHaveBeenCalled();
});
it("clears a recovery failure when a newer launch error arrives", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
const { rerender } = render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
rerender(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{
...error,
stamp: "launch-stamp-2",
recovery_actions: ["retry_launch"],
}}
/>,
);
await waitFor(() => expect(screen.queryByTestId("task-launch-recovery-error")).toBeNull());
});
it("renders a typed summary error without recovery actions", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
statusSummary={{
revision: 1,
updated_at: "2026-08-19T10:00:00Z",
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: [] },
}}
runErrors={[]}
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
});
it("renders a task-wide launch error while a prior session is selected", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="prior-session"
statusSummary={{
revision: 1,
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: ["mark_review_done"] },
}}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("task-launch-mark_review_done-button")).toBeTruthy();
});
it("renders the bounded preview and sends the exact task recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[
{
id: TASK_REPOSITORY_ID,
@@ -127,7 +223,7 @@ describe("TaskLaunchErrorEntry", () => {
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
@@ -144,7 +240,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -165,7 +261,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -187,13 +283,13 @@ describe("TaskLaunchErrorEntry", () => {
<>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
@@ -210,6 +306,6 @@ describe("TaskLaunchErrorEntry", () => {
expect(buttons[1].getAttribute("disabled")).not.toBeNull();
});
resolveRequest?.({ ok: true });
await waitFor(() => expect(screen.queryAllByTestId("task-launch-error-entry")).toHaveLength(2));
await waitFor(() => expect(screen.queryAllByTestId(ERROR_ENTRY_TEST_ID)).toHaveLength(2));
});
});
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
IconAlertTriangle,
IconCheck,
@@ -9,7 +9,7 @@ import {
IconRefresh,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useToast } from "@/components/toast-provider";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error";
@@ -26,10 +26,64 @@ type TaskLaunchErrorEntryProps = {
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
function useTaskLaunchRecovery({
taskId,
error,
}: Pick<TaskLaunchErrorEntryProps, "taskId" | "error">) {
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const [recoveryError, setRecoveryError] = useState(false);
useEffect(() => {
setRecoveryError(false);
}, [error.stamp]);
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
setRecoveryError(false);
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
setRecoveryError(true);
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return { pendingAction, recoveryError, sendRecovery };
}
const TASK_LAUNCH_ERROR_CATEGORIES = new Set([
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
]);
@@ -51,6 +105,8 @@ function categoryTitle(category: string | undefined, t: (key: string) => string)
return t("task:launchErrorDefaultBranchUnresolved");
case "pr_already_closed":
return t("task:launchErrorPrAlreadyClosed");
case "workspace_checkout_failed":
return t("task:launchErrorWorkspaceCheckoutFailed");
default:
return t("task:launchErrorGeneric");
}
@@ -64,6 +120,8 @@ function actionLabel(action: TaskLaunchRecoveryAction, t: (key: string) => strin
return t("task:pickBaseBranch");
case "mark_review_done":
return t("task:markReviewDone");
case "retry_launch":
return t("task:retryLaunch");
}
}
@@ -76,6 +134,7 @@ function RecoveryActionIcon({
}) {
if (pending) return <IconLoader2 className="h-3 w-3 animate-spin" />;
if (action === "retry_default") return <IconRefresh className="h-3 w-3" />;
if (action === "retry_launch") return <IconRefresh className="h-3 w-3" />;
if (action === "pick_base_branch") return <IconGitBranch className="h-3 w-3" />;
return <IconCheck className="h-3 w-3" />;
}
@@ -99,14 +158,14 @@ function TaskLaunchRecoveryActions({
}) {
const { t } = useTranslation();
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
<div className="mt-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{actions.map((action) => {
const opensBranchPicker = action === "pick_base_branch";
const button = (
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 max-w-full cursor-pointer gap-1.5 text-xs sm:min-h-8"
className="h-auto min-h-11 w-full cursor-pointer justify-start gap-1.5 text-xs sm:w-auto sm:min-h-8"
disabled={pendingAction !== null}
onClick={opensBranchPicker ? undefined : () => void onRecover(action)}
data-testid={`task-launch-${action}-button`}
@@ -139,58 +198,16 @@ export function TaskLaunchErrorEntry({
repositories,
}: TaskLaunchErrorEntryProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
const [showDetails, setShowDetails] = useState(false);
const taskRepository = repositories?.find(
(repository) => repository.id === error.task_repository_id,
);
const currentBase = taskRepository?.base_branch ?? t("task:baseBranchNotSet");
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
toast({
title: t("task:launchRecoveryFailed"),
description: t("task:tryRecoveryAgain"),
variant: "error",
});
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return (
<div
className="flex min-w-0 gap-3 border-b border-border/50 py-3"
className="flex min-w-0 gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 sm:p-4"
data-testid="task-launch-error-entry"
role="alert"
>
@@ -205,6 +222,27 @@ export function TaskLaunchErrorEntry({
<p className="mt-1 max-w-prose break-words text-sm text-muted-foreground">
{error.preview}
</p>
<p className="mt-2 text-xs text-muted-foreground" data-testid="task-launch-no-change">
{t("task:launchErrorNoChanges")}
</p>
{error.details && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-3">
<CollapsibleTrigger className="flex min-h-11 items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer sm:min-h-8">
<IconRefresh
className={`h-3.5 w-3.5 transition-transform ${showDetails ? "rotate-90" : ""}`}
/>
{t("task:showDetails")}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
className="mt-2 max-w-prose whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"
data-testid="task-launch-error-details"
>
{error.details}
</pre>
</CollapsibleContent>
</Collapsible>
)}
<TaskLaunchRecoveryActions
actions={error.recovery_actions ?? []}
workspaceId={workspaceId}
@@ -214,6 +252,15 @@ export function TaskLaunchErrorEntry({
pendingAction={pendingAction}
onRecover={sendRecovery}
/>
{recoveryError && (
<p
className="mt-2 text-xs text-destructive"
data-testid="task-launch-recovery-error"
role="alert"
>
{t("task:launchRecoveryFailed")} {t("task:tryRecoveryAgain")}
</p>
)}
</div>
</div>
);
@@ -35,6 +35,7 @@ import type {
import {
buildLaterAgentReplyMap,
buildRunErrorsFromSessions,
filterVisibleRunErrors,
mergeChatEntries,
type ChatEntry,
} from "./chat-entries";
@@ -615,6 +616,10 @@ export function TaskChat({
);
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
const visibleRunErrors = useMemo(
() => filterVisibleRunErrors(runErrors, statusSummary?.active_error),
[runErrors, statusSummary?.active_error],
);
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
const entries = useMemo(
() =>
@@ -624,10 +629,10 @@ export function TaskChat({
groups: renderedGroups,
decisions,
turnCtx,
runErrors,
runErrors: visibleRunErrors,
laterAgentReplyMap,
}),
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
[comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
);
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
@@ -652,7 +657,6 @@ export function TaskChat({
taskId={taskId}
workspaceId={workspaceId}
statusSummary={statusSummary}
runErrors={runErrors}
repositories={repositories}
/>
{isEmpty ? (
@@ -0,0 +1,213 @@
import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/lib/types/http";
const launchError = {
stamp: "task-wide-launch-error",
occurred_at: "2026-08-20T10:00:00Z",
preview: "The workspace could not be prepared.",
category: "workspace_checkout_failed",
};
const priorTranscriptMessage = {
id: "prior-transcript",
author_type: "agent",
content: "The prior session stopped after its checkout failed.",
type: "status",
created_at: "2026-08-19T10:00:00Z",
} as unknown as Message;
const appStoreState = {
userSettings: {
showAnchoredPromptBar: false,
showScrollToLastPrompt: false,
showScrollToStart: false,
},
taskSessions: {
items: {
"prior-session": { name: null, agent_profile_id: null, last_read_message_id: null },
},
},
agentProfiles: { items: [] },
messages: { bySession: { "prior-session": [priorTranscriptMessage] } },
};
const panelState = {
resolvedSessionId: "prior-session",
session: { state: "FAILED", error_message: "The prior session stopped." },
taskId: "task-1",
isWorking: false,
messagesLoading: false,
historyRefreshPending: false,
isInitialMessagesLoading: false,
groupedItems: [],
allMessages: [priorTranscriptMessage],
footerActionMessages: [],
permissionsByToolCallId: new Map(),
childrenByParentToolCallId: new Map(),
agentMessageCount: 0,
pendingClarification: null,
pendingClarificationGroup: null,
};
vi.mock("./panel-primitives", () => ({
PanelRoot: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PanelBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof appStoreState) => unknown) => selector(appStoreState),
useAppStoreApi: () => ({
getState: () => appStoreState,
}),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({
useSettingsData: () => undefined,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false }),
}));
vi.mock("./task-archived-context", () => ({
useIsTaskArchived: () => false,
}));
vi.mock("./task-launch-error-context", () => ({
useTaskLaunchErrorContext: () => ({
taskId: "task-1",
workspaceId: "workspace-1",
statusSummary: { active_error: launchError },
repositories: [],
}),
}));
vi.mock("@/hooks/domains/task/use-task-status-summary", () => ({
useTaskStatusSummary: () => ({ active_error: launchError }),
}));
vi.mock("./chat/use-chat-panel-state", () => ({
useChatPanelState: () => panelState,
}));
vi.mock("./chat/chat-input-area", () => ({
ChatInputArea: ({
launchErrorOwned,
panelState: state,
}: {
launchErrorOwned?: boolean;
panelState: typeof panelState;
}) => (
<div data-testid="chat-footer">
{!launchErrorOwned && state.session?.state === "FAILED" && (
<div data-testid="session-stopped-banner">prior session recovery</div>
)}
</div>
),
useSubmitHandler: () => ({ isSending: false, handleSubmit: vi.fn() }),
useChatPanelHandlers: () => ({ handleCancelTurn: vi.fn() }),
}));
vi.mock("@/components/task/chat/message-list", () => ({
MessageList: ({
messages,
launchErrorOwned,
}: {
messages: Message[];
launchErrorOwned?: boolean;
}) => (
<div data-testid="message-list">
{!launchErrorOwned &&
messages.map((message) => <div key={message.id}>{message.content}</div>)}
</div>
),
}));
vi.mock("./simple/components/task-chat-launch-error", () => ({
TaskChatLaunchError: ({
statusSummary,
}: {
statusSummary?: { active_error?: typeof launchError };
}) =>
statusSummary?.active_error ? (
<div data-testid="task-launch-error-entry">{statusSummary.active_error.preview}</div>
) : null,
}));
vi.mock("@/components/shared/task-markdown-file-link-provider", () => ({
TaskMarkdownFileLinkProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./chat/clarification-panel-section", () => ({
ClarificationPanelSection: () => null,
}));
vi.mock("./chat/use-composer-agent-start-hint", () => ({
useComposerAgentStartHint: () => false,
}));
vi.mock("@/hooks/use-panel-search", () => ({
usePanelSearch: () => undefined,
}));
vi.mock("@/hooks/domains/session/use-session-search", () => ({
useSessionSearch: () => ({
isOpen: false,
query: "",
hits: [],
activeHitId: null,
isSearching: false,
setQuery: vi.fn(),
setActiveHit: vi.fn(),
open: vi.fn(),
close: vi.fn(),
}),
}));
vi.mock("@/hooks/use-lazy-load-messages", () => ({
useLazyLoadMessages: () => ({ loadMoreRaw: vi.fn(), hasMore: false }),
}));
vi.mock("@/components/task/chat/use-drain-older-messages", () => ({
useDrainOlderMessages: () => undefined,
}));
vi.mock("./chat/use-session-read-tracking", () => ({
useSessionReadTracking: () => null,
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: { scrollTarget: null }) => unknown) =>
selector({ scrollTarget: null }),
getState: () => ({ scrollTarget: null }),
}));
vi.mock("@/hooks/domains/session/load-message-window", () => ({
loadMessageWindowAround: vi.fn(),
}));
vi.mock("@/lib/session-workspace-path", () => ({
getSessionWorkspacePath: () => undefined,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { TaskChatPanel } from "./task-chat-panel";
afterEach(() => cleanup());
describe("TaskChatPanel launch-error ownership", () => {
it("keeps a task-wide card and prior failed-session surfaces together", () => {
render(<TaskChatPanel sessionId="prior-session" taskId="task-1" />);
expect(screen.getByTestId("task-launch-error-entry").textContent).toContain(
launchError.preview,
);
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
expect(screen.getByText(priorTranscriptMessage.content)).toBeTruthy();
});
});
+15 -1
View File
@@ -52,8 +52,10 @@ import { useTranslation } from "react-i18next";
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { isTaskLaunchErrorOwnedBySession } from "@/components/task/chat/types";
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
/** Returns a `clarificationKey` that increments each time a pending
@@ -1024,6 +1026,11 @@ export const TaskChatPanel = memo(function TaskChatPanel({
pendingClarification,
pendingClarificationGroup,
} = panelState;
const activeLaunchError = launchStatusSummary?.active_error;
const launchErrorOwned = Boolean(
isTypedTaskLaunchError(activeLaunchError) &&
isTaskLaunchErrorOwnedBySession(activeLaunchError, resolvedSessionId),
);
const showAgentStartHint = useComposerAgentStartHint(
resolvedSessionId,
session?.state,
@@ -1165,7 +1172,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
taskId={launchErrorContext.taskId}
workspaceId={launchErrorContext.workspaceId}
statusSummary={launchStatusSummary}
runErrors={[]}
sessionId={resolvedSessionId}
repositories={launchErrorContext.repositories}
/>
)}
@@ -1197,6 +1204,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onFirstMessageHiddenChange={setIsFirstMessageHidden}
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
isVisible={transcriptIsVisible}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorOwned ? activeLaunchError?.stamp : undefined}
launchErrorOccurredAt={launchErrorOwned ? activeLaunchError?.occurred_at : undefined}
stickyPromptBar={
showAnchoredBar && lastPromptMessage ? (
<AnchoredLastPromptBar
@@ -1251,6 +1261,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onScrollToStart={scrollToStart}
statusTaskId={statusTaskId ?? taskIdHint}
showAgentStartHint={showAgentStartHint}
launchErrorOwned={launchErrorOwned}
/>
</PanelRoot>
);
@@ -1279,6 +1290,7 @@ type ChatFooterProps = {
statusTaskId: string | null;
/** Recovered-idle sessions render the composer hint (see ChatInputArea). */
showAgentStartHint: boolean;
launchErrorOwned: boolean;
};
/**
@@ -1306,6 +1318,7 @@ function ChatFooter({
onScrollToStart,
statusTaskId,
showAgentStartHint,
launchErrorOwned,
}: ChatFooterProps) {
const { t } = useTranslation();
if (isArchived) {
@@ -1335,6 +1348,7 @@ function ChatFooter({
onScrollToStart={onScrollToStart}
statusTaskId={statusTaskId}
showAgentStartHint={showAgentStartHint}
launchErrorOwned={launchErrorOwned}
/>
);
}
@@ -49,6 +49,12 @@ beforeEach(() => {
});
describe("TaskLaunchErrorProvider", () => {
it("does not toast for a typed launch error while its card is visible", () => {
renderProvider(initialSummary);
expect(toastMock).not.toHaveBeenCalled();
});
it.each(["provider_auth_required", "model_capacity"])(
"does not toast for ordinary active error category %s",
(category) => {
@@ -1,16 +1,9 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useToast } from "@/components/toast-provider";
import { t } from "@/lib/i18n";
import { getSessionStorage, setSessionStorage } from "@/lib/local-storage";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
// i18n-exempt: persisted browser storage key, not user-facing copy.
const LAUNCH_ERROR_TOASTS_KEY = "kandev.task-launch-error-toasts";
export type TaskLaunchErrorContextValue = {
taskId: string;
@@ -28,39 +21,8 @@ export function TaskLaunchErrorProvider({
value: TaskLaunchErrorContextValue;
children: ReactNode;
}) {
const { toast } = useToast();
const shownToastStampsRef = useRef(new Set<string>());
const statusSummary = useTaskStatusSummary(value.taskId, value.statusSummary);
useEffect(() => {
const activeError = statusSummary?.active_error;
if (!isTypedTaskLaunchError(activeError)) return;
const localStamp = `${value.taskId}:${activeError.stamp}`;
if (shownToastStampsRef.current.has(localStamp)) return;
const shownByTask = getSessionStorage<Record<string, string[]>>(LAUNCH_ERROR_TOASTS_KEY, {});
if ((shownByTask[value.taskId] ?? []).includes(activeError.stamp)) {
shownToastStampsRef.current.add(localStamp);
return;
}
shownToastStampsRef.current.add(localStamp);
setSessionStorage(LAUNCH_ERROR_TOASTS_KEY, {
...shownByTask,
[value.taskId]: [...(shownByTask[value.taskId] ?? []), activeError.stamp].slice(-20),
});
toast({
title: t("task:taskFailedToStart"),
description: t("task:launchFailedSeeDetails"),
variant: "error",
});
}, [
statusSummary?.active_error?.category,
statusSummary?.active_error?.stamp,
toast,
value.taskId,
]);
const contextValue = useMemo(() => ({ ...value, statusSummary }), [statusSummary, value]);
return (
<TaskLaunchErrorContext.Provider value={contextValue}>
-1
View File
@@ -93,7 +93,6 @@ export const TaskLayout = memo(function TaskLayout({
taskId={launchErrorContext.taskId}
workspaceId={launchErrorContext.workspaceId}
statusSummary={launchErrorContext.statusSummary}
runErrors={[]}
repositories={launchErrorContext.repositories}
/>
</div>
@@ -24,7 +24,10 @@ async function switchMobileTask(testPage: Page, title: string): Promise<void> {
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title });
await expect(taskRow).toBeVisible({ timeout: 15_000 });
await taskRow.tap();
// This test covers task-switch transcript continuity, not touch activation.
// Use a click so the row's long-press drag sensor cannot consume the gesture
// while the mobile drawer is under CI load.
await taskRow.click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
@@ -71,7 +71,7 @@ test("dragging into a feeder wakes an open pull target without reload", async ({
x: feederBox!.x + feederBox!.width / 2,
y: feederBox!.y + Math.min(160, feederBox!.height / 2),
};
await testPage.mouse.move(feederPoint.x, feederPoint.y);
await testPage.mouse.move(feederPoint.x, feederPoint.y, { steps: 1 });
await expect(feederColumn).toHaveClass(/bg-primary\/5/);
await testPage.mouse.up();
@@ -52,9 +52,12 @@ async function assertUserVisibleFailure(page: Page, taskId: string, expected: Re
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad(30_000);
const banner = session.activeChat().getByTestId("failed-session-banner");
await expect(banner).toBeVisible({ timeout: 30_000 });
await expect(banner).toContainText(expected);
const launchError = session.activeChat().getByTestId("task-launch-error-entry");
await expect(launchError).toBeVisible({ timeout: 30_000 });
await launchError.getByRole("button", { name: "Show details" }).click();
const details = launchError.getByTestId("task-launch-error-details");
await expect(details).toBeVisible();
await expect(details).toContainText(expected);
}
async function createProfile(
@@ -11,9 +11,6 @@ test.describe("Office run-error remediation link", () => {
const task = await apiClient.createTask(officeSeed.workspaceId, "Office remediation link", {
workflow_id: officeSeed.workflowId,
});
await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: officeSeed.agentId,
});
await apiClient.seedTaskSession(task.id, {
state: "FAILED",
agentProfileId: officeSeed.agentId,
@@ -71,9 +68,6 @@ test.describe("Office run-error remediation link", () => {
const task = await apiClient.createTask(officeSeed.workspaceId, title, {
workflow_id: officeSeed.workflowId,
});
await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
assignee_agent_profile_id: officeSeed.agentId,
});
await apiClient.seedTaskSession(task.id, {
state: "FAILED",
agentProfileId: officeSeed.agentId,
@@ -75,7 +75,7 @@ test.describe("mobile PR watcher missing branch", () => {
const recovery = chat.getByTestId("task-launch-error-entry");
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
await expect(recovery).toContainText("The selected base branch is not available.");
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
await expect(recovery).not.toContainText(prBranch);
const actionButtons = recovery.locator("button[data-testid^='task-launch-']");
await expect(actionButtons).not.toHaveCount(0);
@@ -18,7 +18,8 @@ test.describe("PR watcher missing branch", () => {
const prBranch = "feature/already-merged-and-deleted";
// Mock an open PR whose head branch was deleted on the remote.
// Mock an open PR whose head branch was deleted on the remote. The PR
// head cannot be materialized, so this is a workspace-preparation failure.
await apiClient.mockGitHubAddPRs([
{
number: 999,
@@ -82,7 +83,7 @@ test.describe("PR watcher missing branch", () => {
},
{ timeout: 60_000, message: "waiting for the durable PR launch-error projection" },
)
.toBe("base_branch_missing");
.toBe("workspace_checkout_failed");
// --- Navigate to the task session view ---
await testPage.goto(`/t/${task.id}`);
@@ -95,7 +96,7 @@ test.describe("PR watcher missing branch", () => {
const chat = session.activeChat();
const recovery = chat.getByTestId("task-launch-error-entry");
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
await expect(recovery).toContainText("The selected base branch is not available.");
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
await expect(recovery).not.toContainText(prBranch);
await expect(recovery.getByTestId("task-launch-archive-button")).toHaveCount(0);
await expect(recovery.getByTestId("task-launch-delete-button")).toHaveCount(0);
@@ -3,6 +3,7 @@ import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import { createEmptyRemoteRepository } from "../../helpers/empty-remote-repository";
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
useRegularMode();
@@ -342,6 +343,10 @@ test.describe("Task creation from GitHub URL", () => {
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 push origin feature/pr-branch:refs/pull/99/head", {
cwd: repoDir,
env: gitEnv,
});
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
// Pre-seed a GitHub-backed repository
@@ -431,7 +436,6 @@ test.describe("Task creation from GitHub URL", () => {
test.setTimeout(90_000);
const { execSync } = await import("child_process");
const fs = await import("fs");
const gitEnv = {
...process.env,
HOME: backend.tmpDir,
@@ -441,17 +445,21 @@ test.describe("Task creation from GitHub URL", () => {
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 });
// Create a fresh repo with an offline origin that publishes the PR
// snapshot ref used by the worktree manager.
const repository = createEmptyRemoteRepository(backend.tmpDir, "pr-wt");
const repoDir = repository.localPath;
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
execSync("git push origin main", { 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 push origin feature/pr-branch:refs/pull/77/head", {
cwd: repoDir,
env: gitEnv,
});
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
// Register the repo with a unique provider name to avoid collisions with
@@ -540,7 +548,7 @@ test.describe("Task creation from GitHub URL", () => {
await session.expectTerminalHasText("feature/pr-branch");
});
test("shows fetch warning banner when PR branch has no remote", async ({
test("shows a launch error when the PR branch has no remote snapshot", async ({
testPage,
apiClient,
seedData,
@@ -559,9 +567,8 @@ test.describe("Task creation from GitHub URL", () => {
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.
// Create a repo with the PR branch locally but NO remote. PR launches must
// fail closed when the immutable pull-request snapshot cannot be fetched.
const repoDir = `${backend.tmpDir}/repos/e2e-warning-repo`;
fs.mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
@@ -596,7 +603,7 @@ test.describe("Task creation from GitHub URL", () => {
},
]);
// Need worktree executor to trigger fetchBranchToLocal (local executor doesn't produce warnings)
// Need worktree executor to exercise the authenticated PR snapshot fetch.
const { executors } = await apiClient.listExecutors();
const worktreeExec = executors.find((e) => e.type === "worktree");
const worktreeProfile = worktreeExec?.profiles?.[0];
@@ -637,28 +644,17 @@ test.describe("Task creation from GitHub URL", () => {
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,
});
const launchError = session.activeChat().getByTestId("task-launch-error-entry");
await expect(launchError).toBeVisible({ timeout: 30_000 });
await expect(launchError).toContainText(/launch needs attention/i);
// 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" });
const detailsBtn = launchError.getByRole("button", { name: "Show 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();
await expect(launchError.getByTestId("task-launch-error-details")).toContainText(
/pull request head 200|no remote ref|workspace checkout failed/i,
);
await expect(testPage.getByTestId("prepare-warning-banner")).toHaveCount(0);
});
test("two tasks from the same PR URL create independent worktrees", async ({
@@ -670,7 +666,6 @@ test.describe("Task creation from GitHub URL", () => {
test.setTimeout(120_000);
const { execSync } = await import("child_process");
const fs = await import("fs");
const gitEnv = {
...process.env,
HOME: backend.tmpDir,
@@ -680,13 +675,18 @@ test.describe("Task creation from GitHub URL", () => {
GIT_COMMITTER_EMAIL: "e2e@test.local",
};
// Create a repo with the PR branch locally.
const repoDir = `${backend.tmpDir}/repos/e2e-shared-pr`;
fs.mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir, env: gitEnv });
// Create a repo whose offline origin publishes the immutable PR snapshot
// used by both independent worktrees.
const repository = createEmptyRemoteRepository(backend.tmpDir, "shared-pr");
const repoDir = repository.localPath;
execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv });
execSync("git push origin main", { cwd: repoDir, env: gitEnv });
execSync("git checkout -b feature/shared-pr", { cwd: repoDir, env: gitEnv });
execSync('git commit --allow-empty -m "shared pr commit"', { cwd: repoDir, env: gitEnv });
execSync("git push origin feature/shared-pr:refs/pull/50/head", {
cwd: repoDir,
env: gitEnv,
});
execSync("git checkout main", { cwd: repoDir, env: gitEnv });
await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
@@ -221,16 +221,20 @@ test.describe("task launch failure recovery", () => {
expect(launchError.task_repository_id).toBe(taskRepository.id);
expect(launchError.recovery_actions).toEqual(["retry_default", "pick_base_branch"]);
const pointerToast = testPage
.getByTestId("toast-message")
.filter({ hasText: "The task launch failed. Open the task details for recovery actions." });
await expect(pointerToast).toBeVisible({ timeout: 30_000 });
await expect(pointerToast).not.toContainText("branch-that-no-longer-exists");
await expect(
testPage.getByTestId("toast-message").filter({
hasText: "The task launch failed. Open the task details for recovery actions.",
}),
).toHaveCount(0);
const card = testPage.getByTestId("task-launch-error-entry");
await expect(card).toHaveCount(1, { timeout: 30_000 });
await expect(card).toContainText("The selected base branch is not available.");
await expect(card).not.toContainText("branch-that-no-longer-exists");
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
await expect(testPage.getByTestId("prepare-progress-panel")).toHaveCount(0);
await expect(testPage.getByTestId("missing-branch-recovery")).toHaveCount(0);
await expect(testPage.getByTestId("recovery-resume-button")).toHaveCount(0);
restoreSeedRepositoryOrigin(seedData);
await testPage.reload();
@@ -81,6 +81,10 @@ test.describe("mobile task launch failure recovery", () => {
const card = testPage.getByTestId("task-launch-error-entry");
await expect(card).toHaveCount(1, { timeout: 30_000 });
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
await expect(testPage.getByTestId("prepare-progress-panel")).toHaveCount(0);
await expect(testPage.getByTestId("missing-branch-recovery")).toHaveCount(0);
await expect(testPage.getByTestId("recovery-resume-button")).toHaveCount(0);
const actionButtons = card.locator("button[data-testid^='task-launch-']");
await expect(actionButtons).not.toHaveCount(0);
for (const button of await actionButtons.all()) {
+1
View File
@@ -2,6 +2,7 @@ export const TASK_LAUNCH_RECOVERY_ACTIONS = [
"retry_default",
"pick_base_branch",
"mark_review_done",
"retry_launch",
] as const;
export type TaskLaunchRecoveryAction = (typeof TASK_LAUNCH_RECOVERY_ACTIONS)[number];
@@ -7,6 +7,7 @@ export type TaskStatusSummaryActiveError = {
stamp: string;
occurred_at: string;
preview: string;
details?: string;
category?: string;
recovery_actions?: TaskLaunchRecoveryAction[];
};
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "The task could not start because its base branch is unavailable.",
"launchErrorDefaultBranchUnresolved": "The task could not resolve the repository default branch.",
"launchErrorPrAlreadyClosed": "The task is linked to a closed pull request.",
"launchErrorWorkspaceCheckoutFailed": "The task could not prepare its workspace for launch.",
"launchErrorGeneric": "The task could not start.",
"launchErrorSessionPreview": "The task launch needs attention.",
"launchNeedsAttention": "Launch needs attention",
"launchErrorNoChanges": "No task or branch settings were changed.",
"launchPromptPreview": "Launch prompt preview",
"retryWithDefaultBranch": "Retry with default branch",
"pickBaseBranch": "Pick a base branch",
"markReviewDone": "Mark review done",
"retryLaunch": "Retry launch",
"chooseBaseBranch": "Choose a base branch",
"chooseBaseBranchDescription": "Select the branch to use before the task relaunches.",
"baseBranchNotSet": "Base branch not set",
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ ƀēćàũśē ĩţś ƀàśē ƀŕàńćĥ ĩś ũńàvàĩĺàƀĺē.",
"launchErrorDefaultBranchUnresolved": "Ţĥē ţàśķ ćōũĺď ńōţ ŕēśōĺvē ţĥē ŕēƥōśĩţōŕŷ ďēƒàũĺţ ƀŕàńćĥ.",
"launchErrorPrAlreadyClosed": "Ţĥē ţàśķ ĩś ĺĩńķēď ţō à ćĺōśēď ƥũĺĺ ŕēqũēśţ.",
"launchErrorWorkspaceCheckoutFailed": "Ţĥē ţàśķ ćōũĺď ńōţ ƥŕēƥàŕē ĩţś ŵōŕķśƥàćē ƒōŕ ĺàũńćĥ.",
"launchErrorGeneric": "Ţĥē ţàśķ ćōũĺď ńōţ śţàŕţ.",
"launchErrorSessionPreview": "Ţĥē ţàśķ ĺàũńćĥ ńēēďś àţţēńţĩōń.",
"launchNeedsAttention": "Ĺàũńćĥ ńēēďś àţţēńţĩōń",
"launchErrorNoChanges": "Ńō ţàśķ ōŕ ƀŕàńćĥ śēţţĩńĝś ŵēŕē ćĥàńĝēď.",
"launchPromptPreview": "Ĺàũńćĥ ƥŕōḿƥţ ƥŕēvĩēŵ",
"retryWithDefaultBranch": "Ŕēţŕŷ ŵĩţĥ ďēƒàũĺţ ƀŕàńćĥ",
"pickBaseBranch": "Ƥĩćķ à ƀàśē ƀŕàńćĥ",
"markReviewDone": "Ḿàŕķ ŕēvĩēŵ ďōńē",
"retryLaunch": "Ŕēţŕŷ ĺàũńćĥ",
"chooseBaseBranch": "Ćĥōōśē à ƀàśē ƀŕàńćĥ",
"chooseBaseBranchDescription": "Śēĺēćţ ţĥē ƀŕàńćĥ ţō ũśē ƀēƒōŕē ţĥē ţàśķ ŕēĺàũńćĥēś.",
"baseBranchNotSet": "Ɓàśē ƀŕàńćĥ ńōţ śēţ",
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "Não foi possível iniciar a tarefa porque o branch base não está disponível.",
"launchErrorDefaultBranchUnresolved": "Não foi possível resolver o branch predefinido do repositório.",
"launchErrorPrAlreadyClosed": "A tarefa está associada a um pull request fechado.",
"launchErrorWorkspaceCheckoutFailed": "Não foi possível preparar o workspace da tarefa para o arranque.",
"launchErrorGeneric": "Não foi possível iniciar a tarefa.",
"launchErrorSessionPreview": "O início da tarefa requer atenção.",
"launchNeedsAttention": "O início requer atenção",
"launchErrorNoChanges": "As definições da tarefa e do branch não foram alteradas.",
"launchPromptPreview": "Pré-visualização do prompt de início",
"retryWithDefaultBranch": "Tentar novamente com o branch predefinido",
"pickBaseBranch": "Escolher um branch base",
"markReviewDone": "Marcar revisão como concluída",
"retryLaunch": "Tentar iniciar novamente",
"chooseBaseBranch": "Escolher um branch base",
"chooseBaseBranchDescription": "Selecione o branch a usar antes de reiniciar a tarefa.",
"baseBranchNotSet": "Branch base não definido",
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "任务无法启动,因为基础分支不可用。",
"launchErrorDefaultBranchUnresolved": "无法解析仓库的默认分支。",
"launchErrorPrAlreadyClosed": "任务关联的拉取请求已关闭。",
"launchErrorWorkspaceCheckoutFailed": "任务无法准备启动所需的工作区。",
"launchErrorGeneric": "任务无法启动。",
"launchErrorSessionPreview": "任务启动需要处理。",
"launchNeedsAttention": "启动需要处理",
"launchErrorNoChanges": "任务或分支设置未更改。",
"launchPromptPreview": "启动提示词预览",
"retryWithDefaultBranch": "使用默认分支重试",
"pickBaseBranch": "选择基础分支",
"markReviewDone": "标记评审完成",
"retryLaunch": "重试启动",
"chooseBaseBranch": "选择基础分支",
"chooseBaseBranchDescription": "选择任务重新启动前要使用的分支。",
"baseBranchNotSet": "未设置基础分支",
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "任務無法啓動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的拉取要求已關閉。",
"launchErrorWorkspaceCheckoutFailed": "任務無法準備啓動所需的工作區。",
"launchErrorGeneric": "任務無法啓動。",
"launchErrorSessionPreview": "任務啓動需要處理。",
"launchNeedsAttention": "啓動需要處理",
"launchErrorNoChanges": "任務或分支設定未有變更。",
"launchPromptPreview": "啓動提示詞預覽",
"retryWithDefaultBranch": "使用預設分支重試",
"pickBaseBranch": "選擇基礎分支",
"markReviewDone": "標記審查完成",
"retryLaunch": "重試啓動",
"chooseBaseBranch": "選擇基礎分支",
"chooseBaseBranchDescription": "選擇任務重新啓動前要使用的分支。",
"baseBranchNotSet": "未設定基礎分支",
+3
View File
@@ -1999,13 +1999,16 @@
"launchErrorBaseBranchMissing": "任務無法啟動,因為基礎分支不可用。",
"launchErrorDefaultBranchUnresolved": "無法解析儲存庫的預設分支。",
"launchErrorPrAlreadyClosed": "任務關聯的提取要求已關閉。",
"launchErrorWorkspaceCheckoutFailed": "任務無法準備啟動所需的工作區。",
"launchErrorGeneric": "任務無法啟動。",
"launchErrorSessionPreview": "任務啟動需要處理。",
"launchNeedsAttention": "啟動需要處理",
"launchErrorNoChanges": "任務或分支設定沒有變更。",
"launchPromptPreview": "啟動提示詞預覽",
"retryWithDefaultBranch": "使用預設分支重試",
"pickBaseBranch": "選擇基礎分支",
"markReviewDone": "標記審查完成",
"retryLaunch": "重試啟動",
"chooseBaseBranch": "選擇基礎分支",
"chooseBaseBranchDescription": "選擇任務重新啟動前要使用的分支。",
"baseBranchNotSet": "未設定基礎分支",
@@ -0,0 +1,159 @@
---
created: 2026-09-05
status: completed
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
- REQ-TASKS-REMOTE-CONTRIBUTION-TASKS-001
system_design:
- ../../specs/tasks/system-design/task-launch-failure-recovery.md
- ../../specs/tasks/system-design/remote-contribution-tasks.md
legacy_specs: []
---
# Implementation Plan: Task Launch Error Consolidation
## Overview
The change first isolates pull-request refs from reused local branch names.
Then it makes recovery actions category-specific. The final work order gives
one error card ownership of the desktop and phone presentation.
## Confirmed root cause
PR #3408 uses `feature/unit-7-reject-leg-wa-waj` at
`a76c5d8de780b0b857af8195eab4772bb1a5839b`. The shared clone still has the same
local branch from PR #3294 at `f825122f586978ed5e33fe48d61e401a610318e2`.
Neither commit contains the other. The required fetch tried to update the local
branch directly. Git rejected the update as non-fast-forward, and preparation
stopped before the agent started.
The backend stored one typed launch error. The frontend also derived a previous
agent error, a failed preparation row, an empty-turn warning, a generic failed
status, and a stopped-session recovery banner from the same failed start.
## Scope
### In scope
- Preserve a reused local branch and prepare the exact pull-request head.
- Classify checkout preparation separately from base-branch errors.
- Offer only category-valid recovery actions.
- Render one matching launch-error card on desktop and phone.
- Keep bounded technical details in one disclosure.
- Prove the error and recovery flows with unit and Playwright tests.
### Out of scope
- Reconcile provider and local history after an agent starts.
- Change generic runtime-error recovery after successful agent startup.
- Add new provider-specific branch pickers.
- Change pull, push, merge, rebase, or force-push policy.
## Technical approach
### Pull-request ref isolation
Update `apps/backend/internal/worktree/manager_lifecycle.go` to fetch a
pull-request head into the Kandev-owned `refs/kandev/pull/<N>/head` ref. Verify
that full ref before worktree creation. If a same-named local branch has
unrelated history, create a unique task branch from the verified ref using a
task-owned suffix and bounded collision retries. Set a source-branch upstream
only when its commit matches the verified PR start point.
Keep the named local branch unchanged. Continue to use the existing Git
admission, timeout, and non-interactive environment.
### Failure category and recovery
Add `workspace_checkout_failed` and `retry_launch` to
`apps/backend/internal/task/models/launch_errors.go`. Preserve unknown-value
filtering in the task-status projection.
Update `apps/backend/internal/orchestrator/executor/launch_failure.go` so the
failure category selects the action list. A repository row must not add branch
actions to a generic error.
Extend `task.launch.recover` in
`apps/backend/internal/orchestrator/task_launch_recovery.go` and its WebSocket
handler. `retry_launch` must validate the current stamp and relaunch without a
base-branch write.
### Single frontend owner
Derive one ownership flag in `apps/web/components/task/task-chat-panel.tsx` from
the active error, session identity, and stamp. Pass this flag to the transcript
and composer boundaries.
Update `TaskLaunchErrorEntry` with category-specific titles, cause text, a
no-change statement, and a technical-details disclosure. Keep action state and
WebSocket requests in the existing component.
When the flag is active, suppress the matching last-agent-error notice,
preparation row, empty-turn notice, failed-agent status, stopped-session banner,
and focused-task toast. Do not suppress unrelated historical or runtime errors.
### Mobile contract
Desktop and phone use the task Chat surface. The phone card stacks full-width
actions. All touch actions have at least a 44-pixel hit area.
The task transcript owns scrolling. The details disclosure wraps its bounded
content without an inner scroll region. Base-branch selection keeps the existing
`MobilePickerSheet` behavior.
## Tests
- `apps/backend/internal/worktree/manager_pr_head_collision_test.go` covers two
pull requests that reuse one branch name with divergent histories.
- `apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go`
covers category-specific actions.
- `apps/backend/internal/orchestrator/task_launch_recovery_test.go` covers
authorized, stale, and failed `retry_launch` requests.
- Frontend tests cover one-owner arbitration, the details disclosure, action
payloads, launch-error ownership in the composer, recovery-state reset, and
preservation of unrelated runtime errors.
## E2E tests
- `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts` proves one desktop
error card, one retry action, and no matching duplicate surfaces.
- `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts` proves the
same result on `mobile-chrome`, 44-pixel actions, and no horizontal overflow.
These tests map to
`AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.2`,
`AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.7`, and
`AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.8`.
## Work orders
- [x] [Task 01: Isolate reused pull-request refs](task-01-isolate-reused-pr-refs.md)
- [x] [Task 02: Classify launch recovery actions](task-02-classify-launch-recovery-actions.md)
- [x] [Task 03: Consolidate launch-error presentation](task-03-consolidate-launch-error-presentation.md)
## Verification results
- Backend focused race coverage passed: 1,212 tests across worktree, task
models, status summary, and executor packages, plus 11 targeted task-launch
recovery tests.
- Frontend focused coverage passed: 10 Vitest files and 152 tests.
- Review-remediation frontend coverage passed: 7 Vitest files and 75 tests,
including task-wide launch-error visibility, composer ownership, and
recovery-state reset.
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and
`pnpm run i18n:ratchet` passed.
- Managed Playwright coverage passed: desktop launch-failure recovery 3/3 and
mobile launch-failure recovery 2/2.
- The broader orchestrator package run was attempted but exceeded its timeout
in an existing SQLite/concurrency test; the focused race and recovery suites
passed independently.
## Risks
- A ref-selection error can discard local-only commits. Tests must prove that
the existing branch remains unchanged.
- Error events can arrive in a different order from task-status updates. The
owner logic must converge after either event order.
- Old persisted generic errors can contain branch actions. Projection and UI
normalization must not show actions that conflict with the current category.
@@ -0,0 +1,85 @@
---
id: "01-isolate-reused-pr-refs"
title: "Isolate reused pull-request refs"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
- REQ-TASKS-REMOTE-CONTRIBUTION-TASKS-001
acceptance_criteria:
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.2
- AC-TASKS-REMOTE-CONTRIBUTION-TASKS-001.2
system_design:
- ../../specs/tasks/system-design/task-launch-failure-recovery.md
- ../../specs/tasks/system-design/remote-contribution-tasks.md
---
# Task 01: Isolate reused pull-request refs
## Summary
Prepare each pull-request head through a PR-scoped internal ref. Preserve any
same-named local branch that contains different history.
## In scope
- Fetch and verify a pull-request head without updating the named local branch.
- Create a unique task branch when the named branch has unrelated history.
- Preserve Git admission, timeout, credential, and cancellation behavior.
## Out of scope
- Provider-history reconciliation after launch.
- Remote branch mutation.
- Changes to non-pull-request local branch refresh.
## Acceptance
- Two pull requests can reuse one head-branch name without a fetch conflict.
- The new worktree starts at the requested pull-request head.
- The prior local branch and its commit remain unchanged.
## Verification
```bash
cd apps/backend && go test ./internal/worktree -race
```
## Files likely touched
- `apps/backend/internal/worktree/manager_lifecycle.go`
- `apps/backend/internal/worktree/manager_pr_head_collision_test.go`
## Dependencies
None.
## Risks
- Ref cleanup must not remove a user-owned ref.
- A local-ahead branch must keep its local-only commits.
## Parallelism
`sequential`
## Inputs
- Task launch failure recovery design, pull-request checkout isolation.
- Remote contribution task requirement `AC-TASKS-REMOTE-CONTRIBUTION-TASKS-001.2`.
- ADR `2026-08-10-remote-contribution-head-drift`.
## Results
- Added a Kandev-owned `refs/kandev/pull/<N>/head` fetch ref with full-ref
verification and typed `ErrWorkspaceCheckoutFailed` failures.
- PR worktrees now create a unique local branch when the requested source name
already exists, without resetting or updating that branch. The fallback
uses a task-owned suffix and bounded retries for branch-name collisions.
- PR worktrees only track the source branch when its remote-tracking commit
matches the verified PR head. Ordinary `fetch --all --prune` cannot remove
or replace the internal snapshot.
- Recreate restores deleted PR branches from the isolated ref.
- Verification passed: `go test ./internal/worktree` (368 passed).
@@ -0,0 +1,94 @@
---
id: "02-classify-launch-recovery-actions"
title: "Classify launch recovery actions"
status: completed
wave: 2
depends_on:
- "01-isolate-reused-pr-refs"
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
acceptance_criteria:
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.2
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.4
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.8
system_design:
- ../../specs/tasks/system-design/task-launch-failure-recovery.md
---
# Task 02: Classify launch recovery actions
## Summary
Give checkout preparation a stable error category. Derive recovery actions from
the category and add a stamp-checked launch retry.
## In scope
- Add `workspace_checkout_failed` and `retry_launch` wire values.
- Map checkout preparation errors to the new category.
- Make every action list category-specific.
- Relaunch without a base-branch write for `retry_launch`.
- Keep authorization and current-stamp checks before relaunch.
## Out of scope
- New persistence tables.
- Automatic retries.
- Changes to `session.recover`.
## Acceptance
- A checkout error offers only `retry_launch`.
- A generic error does not offer base-branch actions because a repository row exists.
- A stale or foreign retry request makes no change and starts no session.
## Verification
```bash
cd apps/backend && go test ./internal/task/models ./internal/orchestrator/executor ./internal/orchestrator/... -race
```
## Files likely touched
- `apps/backend/internal/worktree/errors.go`
- `apps/backend/internal/task/models/launch_errors.go`
- `apps/backend/internal/task/models/launch_errors_test.go`
- `apps/backend/internal/orchestrator/executor/launch_failure.go`
- `apps/backend/internal/orchestrator/executor/executor_launch_failure_classification_test.go`
- `apps/backend/internal/orchestrator/task_launch_recovery.go`
- `apps/backend/internal/orchestrator/task_launch_recovery_test.go`
- `apps/backend/internal/orchestrator/handlers/handlers.go`
## Dependencies
Task 01.
## Risks
- Old persisted action arrays can conflict with the new category matrix.
- A retry must not clear the current error before launch succeeds.
## Parallelism
`sequential`
## Inputs
- Task launch failure recovery design, error model and recovery contract.
- Existing task-status projection normalization tests.
## Results
- Added the `workspace_checkout_failed` category and `retry_launch` action.
- Checkout failures now use category-specific recovery actions, including
retry without changing a task repository base branch.
- Task-scoped recovery validates the current error stamp and repository
identity before relaunch or mutation.
- Persisted and projected errors normalize action lists by category and keep
bounded technical details.
- Focused race coverage passed: 1,212 tests across worktree, task models,
status summary, and executor packages. The 11 targeted task-launch recovery
tests also passed.
- The broader orchestrator package run was attempted but exceeded its timeout
in an existing SQLite/concurrency test.
@@ -0,0 +1,108 @@
---
id: "03-consolidate-launch-error-presentation"
title: "Consolidate launch-error presentation"
status: completed
wave: 3
depends_on:
- "02-classify-launch-recovery-actions"
plan: "plan.md"
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
acceptance_criteria:
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.2
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.7
- AC-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001.8
system_design:
- ../../specs/tasks/system-design/task-launch-failure-recovery.md
---
# Task 03: Consolidate launch-error presentation
## Summary
Make the task launch card the only primary presentation for its matching error.
Give desktop and phone users the same cause, details, and valid recovery.
## In scope
- Derive separate launch-card visibility and session-surface ownership from
session identity and error stamp.
- Add clear category text, a no-change statement, and a details disclosure.
- Suppress matching duplicate notices, rows, banners, empty-turn copy, and toasts.
- Preserve unrelated historical and runtime errors, including stale
launch-only transcript rows.
- Add all required locale values.
- Add desktop and mobile Playwright coverage.
## Out of scope
- Redesign completed-session UI.
- Change non-launch runtime-error recovery.
- Change the mobile branch picker.
## Acceptance
- One failed start renders one error card after all events converge.
- Recovery failure updates the same card without another error surface.
- Phone actions remain reachable, at least 44 pixels high, and free of horizontal overflow.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps/web && pnpm exec vitest run components/task/simple/components/task-launch-error-entry.test.tsx components/task/chat/chat-input-container.test.tsx components/task/chat/message-list-footer.test.tsx components/task/chat/session-stopped-banner.test.tsx components/task/chat/stopped-banner-props.test.ts components/task/chat/types.test.ts lib/ws/handlers/empty-turn-notice.test.ts
cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps/web && pnpm e2e:run tests/task/launch-failure-recovery.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts
```
## Files likely touched
- `apps/web/components/task/task-chat-panel.tsx`
- `apps/web/components/task/task-launch-error-context.tsx`
- `apps/web/components/task/simple/components/task-launch-error-entry.tsx`
- `apps/web/components/task/chat/message-list-shared.tsx`
- `apps/web/components/task/chat/message-list-footer.tsx`
- `apps/web/components/task/chat/chat-input-container.tsx`
- `apps/web/lib/ws/handlers/empty-turn-notice.ts`
- Focused tests beside these files.
- `apps/web/src/locales/*/task.json`
- `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts`
- `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts`
## Dependencies
Task 02.
## Risks
- Event order can briefly show a derived error before the typed record arrives.
- Broad suppression can hide a later agent runtime error.
## Parallelism
`sequential`
## Inputs
- Task launch failure recovery design, presentation ownership and responsive sections.
- Mobile exemplar `apps/web/components/task/mobile/mobile-picker-sheet.tsx`.
- Existing desktop and mobile launch-failure tests.
## Results
- The typed task launch card is the single owner for its matching session and
error stamp. Matching preparation, empty-turn, agent-error, failed-status,
stopped-session, and toast surfaces are suppressed without hiding unrelated
runtime errors.
- The card now shows category-specific copy, bounded technical details, a
no-change statement, inline recovery failures, and mobile-sized stacked
actions.
- Added complete locale values and generated the pseudo-locale catalog.
- Focused frontend coverage passed: 10 Vitest files and 152 tests.
- `pnpm run typecheck`, `pnpm run lint`, `pnpm run i18n:check`, and
`pnpm run i18n:ratchet` passed.
- Managed Playwright coverage passed: desktop launch-failure recovery 3/3 and
mobile launch-failure recovery 2/2.
- Review-remediation coverage passed: 7 focused Vitest files and 75 tests,
including composer ownership and launch-error recovery-state reset.
@@ -4,7 +4,7 @@ system: tasks
requirements:
- REQ-TASKS-TASK-LAUNCH-FAILURE-RECOVERY-001
created: 2026-08-24
updated: 2026-09-03
updated: 2026-09-06
owners:
- cfl12
---
@@ -45,8 +45,20 @@ actions, exact task-repository identity when applicable, and an idempotency
stamp.
The supported categories are `base_branch_missing`, `pr_already_closed`,
`default_branch_unresolved`, and `generic_launch_failure`. The supported
actions are `retry_default`, `pick_base_branch`, and `mark_review_done`.
`default_branch_unresolved`, `workspace_checkout_failed`, and
`generic_launch_failure`. The supported actions are `retry_launch`,
`retry_default`, `pick_base_branch`, and `mark_review_done`.
The failure category controls the available actions. A repository identity
alone does not make a base-branch action valid.
| Category | Valid actions |
| --- | --- |
| `base_branch_missing` | `retry_default`, `pick_base_branch` |
| `default_branch_unresolved` | `pick_base_branch` |
| `workspace_checkout_failed` | `retry_launch` |
| `pr_already_closed` | `mark_review_done` when the workflow permits it |
| `generic_launch_failure` | `retry_launch` |
The `TaskStatusSummary.active_error` projection selects the newest active
record, limits strings and actions, removes duplicate actions without changing
@@ -64,6 +76,8 @@ the current error stamp; stale stamps fail without mutation.
relaunches.
- `pick_base_branch` validates and persists one selected branch before
relaunch.
- `retry_launch` keeps the task-repository settings unchanged and repeats the
failed launch. It still requires the current error stamp.
- `mark_review_done` is allowed only for a valid terminal workflow step and
when every relevant PR is terminal; it uses the normal task-move service.
@@ -106,6 +120,32 @@ but `retry_default` and `pick_base_branch` must write the resolved base to
the exact `task_repositories` row and that write must succeed before relaunch.
No new table is required.
## Pull-request checkout isolation
A pull-request head uses a Kandev-owned ref that includes the pull-request
number, specifically `refs/kandev/pull/<N>/head`. The fetch can force-update
this internal ref because users do not own it. Ordinary remote refresh and
pruning do not manage this namespace. The fetch never writes directly to a
user-named local branch.
The worktree manager verifies the fetched ref before it selects a start point.
If the named local branch has unrelated history, the manager preserves that
branch. It creates a unique task branch from the verified pull-request ref.
This rule also applies when two pull requests reuse the same source-branch
name. A local branch from the first pull request cannot block the second pull
request. A fallback branch uses a task-owned deterministic suffix and a
bounded retry sequence, so an existing fallback branch cannot make launch fail
because of one random-name collision.
The manager sets `origin/<source-branch>` as upstream only when that ref points
to the verified pull-request start point. A remote branch with different
history is never attached as the worktree upstream.
If Kandev cannot fetch or verify the pull-request ref, preparation fails with a
typed `workspace_checkout_failed` error. The error retains the existing
credential and path redaction rules.
## Failure and security
PR lookup failures launch normally. Remote-default timeout, authentication,
@@ -117,12 +157,49 @@ Initial-prompt errors use a safe generic durable message and the same
stale-event checks as other agent failures. Raw attachment paths and provider
details remain in backend diagnostics and do not reach the durable projection.
## Launch-error presentation ownership
The active `TaskStatusSummary.active_error` record owns the primary launch
error for its matching session and stamp. A task-wide record remains visible
while a prior session is selected, but it owns session surfaces only when no
session is selected. A session-owned record owns surfaces only for its exact
session and stamp. The task Chat surface renders one persistent card from this
record.
The matching launch error suppresses these secondary presentations:
- The previous-agent-error notice.
- The standalone failed preparation row.
- The empty-turn warning.
- The generic failed-agent status row.
- The stopped-session recovery banner.
- A launch-error toast while the user views the affected task.
Only synthetic rows generated by the active launch failure are suppressed;
stale stamped or historical launch-only rows remain visible. An unrelated
historical error keeps its normal transcript presentation. A runtime error that
occurs after agent startup keeps the existing session
recovery surface.
The card shows a category title, a short cause, a no-change statement, and only
valid recovery actions. One disclosure shows bounded technical details. The
disclosure does not show raw Git output, credentials, or local file paths.
A failed recovery updates the same card. It does not add another banner or
toast. A successful recovery removes the card after launch succeeds.
## Responsive presentation
The task Chat surface renders one persistent error card from the shared
projection. Desktop actions are inline; mobile actions wrap and branch
selection uses the existing mobile picker. Both surfaces use the same action
authorization and remain free of horizontal overflow.
The task Chat surface is the entry point on desktop and phone. The card remains
inline because the error and its actions belong to the current task.
Desktop actions use one compact row. Phone actions use a vertical layout with
44-pixel touch targets. The existing mobile picker remains the branch-selection
surface for `base_branch_missing` and `default_branch_unresolved`.
The transcript owns vertical scrolling. Expanded technical details wrap inside
the card and do not create a second scroll region. Both layouts prevent
document-level horizontal overflow.
`useEnsureTaskSession` keeps one request latch after an error. Store updates and
loader identity changes do not start another request. The Retry control starts