From ad70426d46088e1dd2cf9ecaa2f7264136ff8e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 5 Aug 2026 18:23:57 +0100 Subject: [PATCH] feat: rename generated task branches (#2262) --- apps/backend/internal/agent/runtime/git.go | 8 + .../agent/runtime/lifecycle/events.go | 12 +- .../lifecycle/manager_workspace_branch.go | 159 +++++ .../manager_workspace_branch_test.go | 186 ++++++ .../agent/runtime/lifecycle/persistence.go | 9 +- .../internal/agent/runtime/lifecycle/types.go | 1 + apps/backend/internal/backendapp/helpers.go | 1 + .../internal/backendapp/orchestrator.go | 1 + .../backend/internal/mcp/handlers/handlers.go | 27 + .../mcp/handlers/set_task_title_test.go | 83 +++ apps/backend/internal/mcp/server/server.go | 5 +- .../orchestrator/event_handlers_git.go | 69 +- .../orchestrator/event_handlers_git_test.go | 80 +++ apps/backend/internal/orchestrator/service.go | 5 + .../orchestrator/task_title_branch.go | 86 +++ .../orchestrator/task_title_branch_runtime.go | 588 ++++++++++++++++++ .../task_title_branch_service_test.go | 411 ++++++++++++ .../orchestrator/task_title_branch_test.go | 33 + .../task/repository/sqlite/executor.go | 34 + .../sqlite/executor_branch_snapshot_test.go | 40 ++ .../task/repository/sqlite/session.go | 16 + .../task/repository/sqlite/session_test.go | 17 + .../task-create-dialog-selectors.tsx | 3 +- ...task-create-prompt-autocomplete-qa.spec.ts | 1 + apps/web/hooks/use-inline-mention.test.ts | 88 ++- apps/web/hooks/use-inline-mention.ts | 59 +- .../plans/agent-title-branch-renaming/plan.md | 150 +++++ .../task-01-branch-rename-runtime.md | 82 +++ .../task-02-title-tool-integration.md | 67 ++ .../task-03-regression-documentation.md | 76 +++ docs/public/automation-and-mcp.md | 7 + docs/public/git-operations.md | 8 + docs/specs/tasks/agent-generated-titles.md | 75 ++- 33 files changed, 2456 insertions(+), 31 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/git.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go create mode 100644 apps/backend/internal/orchestrator/task_title_branch.go create mode 100644 apps/backend/internal/orchestrator/task_title_branch_runtime.go create mode 100644 apps/backend/internal/orchestrator/task_title_branch_service_test.go create mode 100644 apps/backend/internal/orchestrator/task_title_branch_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/executor_branch_snapshot_test.go create mode 100644 docs/plans/agent-title-branch-renaming/plan.md create mode 100644 docs/plans/agent-title-branch-renaming/task-01-branch-rename-runtime.md create mode 100644 docs/plans/agent-title-branch-renaming/task-02-title-tool-integration.md create mode 100644 docs/plans/agent-title-branch-renaming/task-03-regression-documentation.md diff --git a/apps/backend/internal/agent/runtime/git.go b/apps/backend/internal/agent/runtime/git.go new file mode 100644 index 0000000000..64b60e760b --- /dev/null +++ b/apps/backend/internal/agent/runtime/git.go @@ -0,0 +1,8 @@ +package runtime + +import agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + +// GitOperationResult is the runtime seam's view of an agentctl Git operation. +// The alias keeps lifecycle implementations compatible without making +// higher-level callers import the low-level agentctl package directly. +type GitOperationResult = agentctlclient.GitOperationResult diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index ba985b7106..a4dcaccff8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -114,13 +114,11 @@ func (p *EventPublisher) PublishAgentctlEvent(ctx context.Context, eventType str var worktreeID string var worktreeBranch string - if execution.Metadata != nil { - if id, ok := execution.Metadata[MetadataKeyWorktreeID].(string); ok { - worktreeID = id - } - if branch, ok := execution.Metadata[MetadataKeyWorktreeBranch].(string); ok { - worktreeBranch = branch - } + if id, ok := execution.metadataValue(MetadataKeyWorktreeID); ok { + worktreeID, _ = id.(string) + } + if branch, ok := execution.metadataValue(MetadataKeyWorktreeBranch); ok { + worktreeBranch, _ = branch.(string) } payload := AgentctlEventPayload{ diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go new file mode 100644 index 0000000000..c84918932c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go @@ -0,0 +1,159 @@ +package lifecycle + +import ( + "context" + "fmt" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +// BranchSnapshotError reports that Git renamed the branch, but the primary +// execution snapshot could not be updated. The Git operation must not be +// retried because the branch already has its new name. +type BranchSnapshotError struct { + Cause error +} + +func (e *BranchSnapshotError) Error() string { + if e == nil || e.Cause == nil { + return "branch renamed but branch snapshot persistence failed" + } + return fmt.Sprintf("branch renamed but branch snapshot persistence failed: %v", e.Cause) +} + +func (e *BranchSnapshotError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Retryable is intentionally false: retrying the Git operation after a +// snapshot failure would target the already-renamed branch. +func (e *BranchSnapshotError) Retryable() bool { return false } + +// RenameBranchForSession renames the branch for an existing workspace +// execution. The repo argument is the workspace-relative repository path and +// is empty for the primary repository. +// +// The lifecycle manager owns the in-memory execution state and the +// executors_running row. The latter is updated through an optional narrow +// interface so existing lifecycle test doubles do not need to implement a +// branch-specific persistence method. +func (m *Manager) RenameBranchForSession( + ctx context.Context, + sessionID string, + newName string, + repo string, +) (*agentctlclient.GitOperationResult, error) { + return m.renameBranchForSession(ctx, sessionID, newName, repo, repo == "") +} + +// RenameBranchForSessionWithPrimary is the multi-repository variant of +// RenameBranchForSession. Agentctl still receives the repository subpath, but +// the caller explicitly identifies whether that repository owns the primary +// execution snapshot. +func (m *Manager) RenameBranchForSessionWithPrimary( + ctx context.Context, + sessionID string, + newName string, + repo string, + primary bool, +) (*agentctlclient.GitOperationResult, error) { + return m.renameBranchForSession(ctx, sessionID, newName, repo, primary) +} + +func (m *Manager) renameBranchForSession( + ctx context.Context, + sessionID string, + newName string, + repo string, + primary bool, +) (*agentctlclient.GitOperationResult, error) { + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if newName == "" { + return nil, fmt.Errorf("new branch name is required") + } + if m == nil || m.executionStore == nil { + return nil, fmt.Errorf("lifecycle manager execution store is not configured") + } + + execution, err := m.GetOrEnsureExecution(ctx, sessionID) + if err != nil { + return nil, err + } + if execution == nil { + return nil, fmt.Errorf("execution for session %s is unavailable", sessionID) + } + client := execution.GetAgentCtlClient() + if client == nil { + return nil, fmt.Errorf("agentctl client for session %s is unavailable", sessionID) + } + + result, err := client.GitRenameBranch(ctx, newName, repo) + if err != nil { + return result, err + } + if result == nil { + return nil, fmt.Errorf("agentctl returned no branch rename result") + } + if !result.Success { + return result, nil + } + + // Only the repository identified as primary updates the single primary + // execution metadata/running snapshot. Other repositories have their + // durable branch snapshots updated by the orchestrator. + if primary { + execution.setMetadataValue(MetadataKeyWorktreeBranch, newName) + if updater, ok := m.runningWriter.(interface { + UpdateExecutorRunningWorktreeBranch(context.Context, string, string, string) error + }); ok { + if err := updater.UpdateExecutorRunningWorktreeBranch(ctx, sessionID, execution.ID, newName); err != nil { + return result, &BranchSnapshotError{Cause: err} + } + } + } + + return result, nil +} + +func (e *AgentExecution) setMetadataValue(key string, value interface{}) { + if e == nil { + return + } + e.metadataMu.Lock() + defer e.metadataMu.Unlock() + if e.Metadata == nil { + e.Metadata = make(map[string]interface{}) + } + e.Metadata[key] = value +} + +func (e *AgentExecution) metadataValue(key string) (interface{}, bool) { + if e == nil { + return nil, false + } + e.metadataMu.RLock() + defer e.metadataMu.RUnlock() + value, ok := e.Metadata[key] + return value, ok +} + +func (e *AgentExecution) metadataSnapshot() map[string]interface{} { + if e == nil { + return nil + } + e.metadataMu.RLock() + defer e.metadataMu.RUnlock() + if len(e.Metadata) == 0 { + return nil + } + snapshot := make(map[string]interface{}, len(e.Metadata)) + for key, value := range e.Metadata { + snapshot[key] = value + } + return snapshot +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go new file mode 100644 index 0000000000..09e33cda12 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go @@ -0,0 +1,186 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" +) + +type branchSnapshotWriter struct { + executionID string + branch string + err error +} + +func (w *branchSnapshotWriter) UpsertExecutorRunning(context.Context, *models.ExecutorRunning) error { + return nil +} + +func (w *branchSnapshotWriter) DeleteExecutorRunningBySessionID(context.Context, string) error { + return nil +} + +func (w *branchSnapshotWriter) RepairExecutorRunningDead(context.Context, string) error { + return nil +} + +func (w *branchSnapshotWriter) UpdateExecutorRunningWorktreeBranch(_ context.Context, _, executionID, branch string) error { + w.executionID = executionID + w.branch = branch + return w.err +} + +func TestRenameBranchForSessionUsesRepositoryScopedAgentctlOperation(t *testing.T) { + var got struct { + NewName string `json:"new_name"` + Repo string `json:"repo"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/git/rename-branch" { + t.Fatalf("path = %q, want rename endpoint", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "operation": "rename_branch", + }) + })) + t.Cleanup(server.Close) + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("parse server port: %v", err) + } + + execution := &AgentExecution{ + ID: "execution-1", + SessionID: "session-1", + Metadata: map[string]interface{}{MetadataKeyWorktreeBranch: "feature/provisional-abc"}, + agentctl: agentctlclient.NewClient(parsed.Hostname(), port, newTestLogger()), + } + store := NewExecutionStore() + if err := store.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + mgr := &Manager{executionStore: store} + + result, err := mgr.RenameBranchForSession(t.Context(), "session-1", "feature/final-title-abc", "backend") + if err != nil { + t.Fatalf("RenameBranchForSession returned error: %v", err) + } + if result == nil || !result.Success { + t.Fatalf("result = %#v, want successful git operation", result) + } + if got.NewName != "feature/final-title-abc" || got.Repo != "backend" { + t.Fatalf("request = %#v, want final branch and backend repo", got) + } + if gotBranch, _ := execution.Metadata[MetadataKeyWorktreeBranch].(string); gotBranch != "feature/provisional-abc" { + t.Fatalf("non-primary metadata branch = %q, want unchanged", gotBranch) + } +} + +func TestRenameBranchForSessionUpdatesPrimaryExecutionMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "operation": "rename_branch"}) + })) + t.Cleanup(server.Close) + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("parse server port: %v", err) + } + execution := &AgentExecution{ + ID: "execution-1", + SessionID: "session-1", + Metadata: map[string]interface{}{MetadataKeyWorktreeBranch: "feature/provisional-abc"}, + agentctl: agentctlclient.NewClient(parsed.Hostname(), port, newTestLogger()), + } + store := NewExecutionStore() + if err := store.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + mgr := &Manager{executionStore: store} + writer := &branchSnapshotWriter{} + mgr.SetExecutorRunningWriter(writer) + + _, err = mgr.RenameBranchForSessionWithPrimary(t.Context(), "session-1", "feature/final-title-abc", "backend", true) + if err != nil { + t.Fatalf("RenameBranchForSession returned error: %v", err) + } + if got, _ := execution.Metadata[MetadataKeyWorktreeBranch].(string); got != "feature/final-title-abc" { + t.Fatalf("primary metadata branch = %q, want final branch", got) + } + if writer.branch != "feature/final-title-abc" { + t.Fatalf("running snapshot branch = %q, want final branch", writer.branch) + } + if writer.executionID != "execution-1" { + t.Fatalf("running snapshot execution = %q, want execution-1", writer.executionID) + } +} + +func TestRenameBranchForSessionReportsPrimarySnapshotFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "operation": "rename_branch"}) + })) + t.Cleanup(server.Close) + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatalf("parse server port: %v", err) + } + execution := &AgentExecution{ + ID: "execution-rotated", + SessionID: "session-rotated", + Metadata: map[string]interface{}{MetadataKeyWorktreeBranch: "feature/provisional-abc"}, + agentctl: agentctlclient.NewClient(parsed.Hostname(), port, newTestLogger()), + } + store := NewExecutionStore() + if err := store.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + writer := &branchSnapshotWriter{err: models.ErrExecutionRotated} + mgr := &Manager{executionStore: store} + mgr.SetExecutorRunningWriter(writer) + + result, err := mgr.RenameBranchForSessionWithPrimary( + t.Context(), "session-rotated", "feature/final-title-abc", "", true, + ) + if err == nil { + t.Fatal("RenameBranchForSession returned nil error, want snapshot failure") + } + var snapshotErr *BranchSnapshotError + if !errors.As(err, &snapshotErr) { + t.Fatalf("error = %v, want BranchSnapshotError", err) + } + if snapshotErr.Retryable() { + t.Fatal("snapshot failure is retryable, want non-retryable") + } + if !errors.Is(err, models.ErrExecutionRotated) { + t.Fatalf("error = %v, want ErrExecutionRotated", err) + } + if result == nil || !result.Success { + t.Fatalf("result = %#v, want successful Git result", result) + } + if got, _ := execution.Metadata[MetadataKeyWorktreeBranch].(string); got != "feature/final-title-abc" { + t.Fatalf("primary metadata branch = %q, want final branch", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence.go b/apps/backend/internal/agent/runtime/lifecycle/persistence.go index 9a737ba91f..0b2ac9f7b4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence.go @@ -71,6 +71,7 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor lastSeenAt = &now } + metadata := execution.metadataSnapshot() running := &models.ExecutorRunning{ ID: execution.SessionID, SessionID: execution.SessionID, @@ -84,10 +85,10 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor AgentctlURL: agentctlURL, AgentctlPort: agentctlPort, PID: pid, - WorktreeID: getMetadataString(execution.Metadata, MetadataKeyWorktreeID), - WorktreePath: getMetadataString(execution.Metadata, "worktree_path"), - WorktreeBranch: getMetadataString(execution.Metadata, MetadataKeyWorktreeBranch), - Metadata: FilterPersistentMetadata(execution.Metadata), + WorktreeID: getMetadataString(metadata, MetadataKeyWorktreeID), + WorktreePath: getMetadataString(metadata, "worktree_path"), + WorktreeBranch: getMetadataString(metadata, MetadataKeyWorktreeBranch), + Metadata: FilterPersistentMetadata(metadata), LastSeenAt: lastSeenAt, } if prior != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index 3e84d426d7..01ee1af2d7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -55,6 +55,7 @@ type AgentExecution struct { ErrorMessage string ProviderError *streams.ProviderError Metadata map[string]interface{} + metadataMu sync.RWMutex // runtimeEnv is the effective environment used to create the task's // runtime instance. It is kept in memory only so authorized task-scoped // terminals and passthrough processes can inherit the same credentials and diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index fe545093bb..7c5fba4c43 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1487,6 +1487,7 @@ func registerMCPAndDebugRoutes( mcpHandlers.SetClarificationInputPauser(p.orchestratorSvc) mcpHandlers.SetPromptReferenceResolver(p.services.Prompts) mcpHandlers.SetTaskStopper(p.orchestratorSvc) + mcpHandlers.SetTaskTitleBranchRenamer(p.orchestratorSvc) mcpHandlers.SetUserSettingsProvider(p.services.User) if p.systemSvc != nil && p.systemSvc.LogBundles != nil { mcpHandlers.SetDiagnosticBundleServices(p.systemSvc.LogBundles, p.lifecycleMgr) diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index c580f794d7..dff36fce95 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -106,6 +106,7 @@ func provideOrchestrator( orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, secretStore, msgQueue, log) orchestratorSvc.SetAttachmentReader(taskSvc.AttachmentService()) + orchestratorSvc.SetTitleBranchRuntime(lifecycleMgr) if githubSvc != nil { orchestratorSvc.SetGitHubCredentialBroker( githubExecutorCredentialLeaseAdapter{service: githubSvc}, diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 94e22b67dc..b5217a867b 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -155,6 +155,12 @@ type TaskStopper interface { StopTaskForCoordinator(ctx context.Context, taskID string) (orchestrator.CoordinatorTaskStopResult, error) } +// TaskTitleBranchRenamer performs the best-effort branch side effect after an +// owner session accepts a prompt-first task title. +type TaskTitleBranchRenamer interface { + RenameGeneratedBranchesForTaskTitle(ctx context.Context, taskID, sessionID, title string) (orchestrator.TitleBranchRenameResult, error) +} + // MessageQueuer queues a prompt message for delivery to a session on its next turn. // TakeQueued is exposed so move_task can roll back the hand-off prompt when the // underlying MoveTask call fails — without it, a queued "you were moved..." @@ -193,6 +199,7 @@ type Handlers struct { walkthroughService *service.WalkthroughService sessionLauncher SessionLauncher taskStopper TaskStopper + titleBranchRenamer TaskTitleBranchRenamer stopTaskGetter func(context.Context, string) (*models.Task, error) messageQueue MessageQueuer promptResolver PromptReferenceResolver @@ -281,6 +288,12 @@ func (h *Handlers) SetTaskStopper(stopper TaskStopper) { h.taskStopper = stopper } +// SetTaskTitleBranchRenamer wires the best-effort branch rename performed +// after an accepted agent-generated title. +func (h *Handlers) SetTaskTitleBranchRenamer(renamer TaskTitleBranchRenamer) { + h.titleBranchRenamer = renamer +} + // SetUserSettingsProvider wires portable user preferences into MCP task creation. func (h *Handlers) SetUserSettingsProvider(provider UserSettingsProvider) { h.userSettingsProvider = provider @@ -1358,6 +1371,20 @@ func (h *Handlers) handleSetTaskTitle(ctx context.Context, msg *ws.Message) (*ws } if !accepted { result["reason"] = reason + return ws.NewResponse(msg.ID, msg.Action, result) + } + if h.titleBranchRenamer != nil { + branchResult, branchErr := h.titleBranchRenamer.RenameGeneratedBranchesForTaskTitle(ctx, req.TaskID, req.SessionID, task.Title) + if branchErr != nil { + h.logger.Warn("failed to rename generated task branches", zap.String("task_id", req.TaskID), zap.Error(branchErr)) + branchResult = orchestrator.TitleBranchRenameResult{ + Status: orchestrator.TitleBranchStatusFailed, + Failed: []orchestrator.TitleBranchFailure{{Message: branchErr.Error()}}, + } + } + result["branch_rename"] = branchResult + } else { + result["branch_rename"] = orchestrator.TitleBranchRenameResult{Status: orchestrator.TitleBranchStatusNotApplicable} } return ws.NewResponse(msg.ID, msg.Action, result) } diff --git a/apps/backend/internal/mcp/handlers/set_task_title_test.go b/apps/backend/internal/mcp/handlers/set_task_title_test.go index e3f3157850..d631db8d07 100644 --- a/apps/backend/internal/mcp/handlers/set_task_title_test.go +++ b/apps/backend/internal/mcp/handlers/set_task_title_test.go @@ -3,10 +3,12 @@ package handlers import ( "context" "encoding/json" + "errors" "strings" "testing" "time" + "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" @@ -44,6 +46,9 @@ func TestHandleSetTaskTitle_UpdatesPendingTitleOnce(t *testing.T) { "task_id": "task-title", "title": "Useful Feature", }) + var acceptedPayload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &acceptedPayload)) + require.Equal(t, "not_applicable", acceptedPayload["branch_rename"].(map[string]interface{})["status"]) updated, err := svc.GetTask(ctx, "task-title") require.NoError(t, err) @@ -84,6 +89,84 @@ func TestHandleSetTaskTitle_ValidatesInput(t *testing.T) { assertWSError(t, resp, ws.ErrorCodeValidation) } +type titleBranchRenamerStub struct { + calls int + err error +} + +func (s *titleBranchRenamerStub) RenameGeneratedBranchesForTaskTitle(_ context.Context, _, _, title string) (orchestrator.TitleBranchRenameResult, error) { + s.calls++ + if s.err != nil { + return orchestrator.TitleBranchRenameResult{}, s.err + } + return orchestrator.TitleBranchRenameResult{ + Status: orchestrator.TitleBranchStatusRenamed, + Renamed: []orchestrator.TitleBranchRename{{To: "feature/" + strings.ToLower(strings.ReplaceAll(title, " ", "-"))}}, + }, nil +} + +func TestHandleSetTaskTitleKeepsAcceptedTitleWhenBranchRenameFails(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title-branch-failure", Name: "Titles", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-title-branch-failure", WorkspaceID: "ws-title-branch-failure", Title: "Temporary title", Description: "Prompt", + State: v1.TaskStateInProgress, + Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true, models.MetaKeyAgentTitleOwnerSessionID: "session-title-branch-failure"}, + CreatedAt: now, UpdatedAt: now, + })) + renamer := &titleBranchRenamerStub{err: errors.New("git rename failed")} + h := &Handlers{taskSvc: svc, titleBranchRenamer: renamer, logger: testLogger(t).WithFields()} + resp, err := h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{ + "task_id": "task-title-branch-failure", "session_id": "session-title-branch-failure", "title": "Final Title", + })) + require.NoError(t, err) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &payload)) + require.Equal(t, true, payload["accepted"]) + require.Equal(t, "failed", payload["branch_rename"].(map[string]interface{})["status"]) + updated, err := svc.GetTask(ctx, "task-title-branch-failure") + require.NoError(t, err) + require.Equal(t, "Final Title", updated.Title) + require.False(t, models.IsAgentTitlePending(updated.Metadata)) +} + +func TestHandleSetTaskTitleIncludesBranchRenameOutcomeOnlyAfterAcceptedTitle(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-title-branch", Name: "Titles", CreatedAt: now, UpdatedAt: now})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "task-title-branch", WorkspaceID: "ws-title-branch", Title: "Temporary title", Description: "Prompt", + State: v1.TaskStateInProgress, + Metadata: map[string]interface{}{models.MetaKeyAgentTitlePending: true, models.MetaKeyAgentTitleOwnerSessionID: "session-title-branch"}, + CreatedAt: now, UpdatedAt: now, + })) + rename := &titleBranchRenamerStub{} + h := &Handlers{taskSvc: svc, titleBranchRenamer: rename, logger: testLogger(t).WithFields()} + + resp, err := h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{ + "task_id": "task-title-branch", "session_id": "session-title-branch", "title": "Final Title", + })) + require.NoError(t, err) + assertTaskTitleResponse(t, resp, map[string]interface{}{"accepted": true}) + var accepted map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &accepted)) + require.Equal(t, "renamed", accepted["branch_rename"].(map[string]interface{})["status"]) + require.Equal(t, 1, rename.calls) + + resp, err = h.handleSetTaskTitle(ctx, makeWSMessage(t, ws.ActionMCPSetTaskTitle, map[string]interface{}{ + "task_id": "task-title-branch", "session_id": "session-title-branch", "title": "Late Title", + })) + require.NoError(t, err) + var rejected map[string]interface{} + require.NoError(t, json.Unmarshal(resp.Payload, &rejected)) + _, hasBranchRename := rejected["branch_rename"] + require.False(t, hasBranchRename, "rejected title must not invoke branch rename") + require.Equal(t, 1, rename.calls) +} + func TestHandleSetTaskTitleRejectsNonOwner(t *testing.T) { svc, repo := newTestTaskService(t) ctx := context.Background() diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 1f5457eeb3..856f1ede64 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -1121,7 +1121,10 @@ the prompt; call this tool even when that provisional title looks usable. Use a concise title targeting about 6 words. Write a short title phrase, not a sentence or a progress update. Your title should -summarize the requested outcome and will replace the provisional title. Use sentence case: +summarize the requested outcome and will replace the provisional title. For tasks created +without a title, Kandev also uses this final title when naming Kandev-generated branches; +branches checked out from a remote link (such as a GitHub PR) and local-executor branches +are intentionally preserved. Use sentence case: capitalize only the first word and proper nouns (for example, "Improve task title casing", not "Improve Task Title Casing").`), mcp.WithString(titleArg, mcp.Required(), mcp.Description("Short sentence-case task title targeting about 6 words.")), diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index 4940ea933d..63027d4ac4 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "path/filepath" "strings" "sync" "time" @@ -757,7 +758,7 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven // renaming or switching branches (e.g. `git branch -m`, `git checkout`) // leaves PR auto-association stuck on the original branch. if data.BranchSwitch.CurrentBranch != "" { - if err := s.repo.UpdateTaskSessionWorktreeBranch(ctx, data.SessionID, data.BranchSwitch.CurrentBranch); err != nil { + if err := s.updateBranchSwitchWorktreeSnapshot(ctx, data.SessionID, data.BranchSwitch.RepositoryName, data.BranchSwitch.CurrentBranch); err != nil { s.logger.Error("failed to update session worktree branch after branch switch", zap.String("session_id", data.SessionID), zap.String("current_branch", data.BranchSwitch.CurrentBranch), @@ -790,6 +791,72 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven } } +// updateBranchSwitchWorktreeSnapshot scopes a multi-repository branch event to +// the worktree whose path basename matches agentctl's RepositoryName tag. Older +// events and single-repository rows retain the all-worktrees fallback. +func (s *Service) updateBranchSwitchWorktreeSnapshot(ctx context.Context, sessionID, repositoryName, branch string) error { + if repositoryName == "" { + return s.repo.UpdateTaskSessionWorktreeBranch(ctx, sessionID, branch) + } + scoped, ok := s.repo.(titleBranchScopedSnapshotStore) + if !ok { + return s.repo.UpdateTaskSessionWorktreeBranch(ctx, sessionID, branch) + } + lister, ok := s.repo.(titleBranchWorktreeLister) + if !ok { + return s.repo.UpdateTaskSessionWorktreeBranch(ctx, sessionID, branch) + } + worktrees, err := lister.ListTaskSessionWorktrees(ctx, sessionID) + if err != nil { + return err + } + matched := matchingBranchSwitchWorktrees(worktrees, repositoryName) + if len(matched) == 0 { + if repositoryName != "" { + return nil + } + return s.repo.UpdateTaskSessionWorktreeBranch(ctx, sessionID, branch) + } + return s.persistBranchSwitchWorktrees(ctx, scoped, sessionID, branch, matched) +} + +func matchingBranchSwitchWorktrees(worktrees []*models.TaskSessionWorktree, repositoryName string) map[string]string { + matched := make(map[string]string) + for _, worktree := range worktrees { + if worktree == nil || worktree.RepositoryID == "" { + continue + } + if filepath.Base(filepath.Clean(worktree.WorktreePath)) == repositoryName { + matched[worktree.RepositoryID] = worktree.WorktreeID + } + } + if len(matched) == 0 && len(worktrees) == 1 && worktrees[0] != nil { + matched[worktrees[0].RepositoryID] = worktrees[0].WorktreeID + } + return matched +} + +func (s *Service) persistBranchSwitchWorktrees( + ctx context.Context, + scoped titleBranchScopedSnapshotStore, + sessionID string, + branch string, + matched map[string]string, +) error { + for repositoryID, worktreeID := range matched { + if exact, exactOK := s.repo.(titleBranchWorktreeSnapshotStore); exactOK && worktreeID != "" { + if err := exact.UpdateTaskSessionWorktreeBranchByWorktree(ctx, sessionID, worktreeID, branch); err != nil { + return err + } + continue + } + if err := scoped.UpdateTaskSessionWorktreeBranchByRepository(ctx, sessionID, repositoryID, branch); err != nil { + return err + } + } + return nil +} + // resetPRWatchForBranchSwitch re-points the session's existing PR watch to the // new branch and marks it as searching (pr_number=0) so the poller will // discover the PR for the new branch on its next tick. If no watch exists this diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go index cf2d2acafd..02de6b45b2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "path/filepath" "testing" "time" @@ -71,6 +72,85 @@ func TestHandleBranchSwitched_UpdatesWorktreeBranch(t *testing.T) { } } +func TestHandleBranchSwitched_RepositoryScopedUpdateKeepsSiblingBranch(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + taskRoot := t.TempDir() + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t-multi", "s-multi", "step1") + for _, repository := range []*models.Repository{ + {ID: "repo-backend", WorkspaceID: "ws1", Name: "backend", CreatedAt: now, UpdatedAt: now}, + {ID: "repo-frontend", WorkspaceID: "ws1", Name: "frontend", CreatedAt: now, UpdatedAt: now}, + } { + require.NoError(t, testRepo.CreateRepository(ctx, repository)) + } + require.NoError(t, testRepo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt-multi-backend", SessionID: "s-multi", WorktreeID: "worktree-backend", RepositoryID: "repo-backend", + WorktreePath: filepath.Join(taskRoot, "backend"), WorktreeBranch: "feature/backend-old", Position: 0, CreatedAt: now, + })) + require.NoError(t, testRepo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt-multi-frontend", SessionID: "s-multi", WorktreeID: "worktree-frontend", RepositoryID: "repo-frontend", + WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "feature/frontend-old", Position: 1, CreatedAt: now, + })) + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + svc.handleBranchSwitched(ctx, watcher.GitEventData{ + TaskID: "t-multi", SessionID: "s-multi", + BranchSwitch: &lifecycle.GitBranchSwitchData{ + PreviousBranch: "feature/backend-old", CurrentBranch: "feature/backend-new", RepositoryName: "backend", + }, + }) + + worktrees, err := testRepo.ListTaskSessionWorktrees(ctx, "s-multi") + require.NoError(t, err) + require.Len(t, worktrees, 2) + branches := make(map[string]string, len(worktrees)) + for _, worktree := range worktrees { + branches[worktree.RepositoryID] = worktree.WorktreeBranch + } + require.Equal(t, "feature/backend-new", branches["repo-backend"]) + require.Equal(t, "feature/frontend-old", branches["repo-frontend"]) +} + +func TestHandleBranchSwitched_UnknownRepositoryNameDoesNotOverwriteSiblings(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + taskRoot := t.TempDir() + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t-multi-unknown", "s-multi-unknown", "step1") + for _, repository := range []*models.Repository{ + {ID: "repo-unknown-backend", WorkspaceID: "ws1", Name: "backend", CreatedAt: now, UpdatedAt: now}, + {ID: "repo-unknown-frontend", WorkspaceID: "ws1", Name: "frontend", CreatedAt: now, UpdatedAt: now}, + } { + require.NoError(t, testRepo.CreateRepository(ctx, repository)) + } + require.NoError(t, testRepo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt-unknown-backend", SessionID: "s-multi-unknown", WorktreeID: "worktree-unknown-backend", RepositoryID: "repo-unknown-backend", + WorktreePath: filepath.Join(taskRoot, "backend"), WorktreeBranch: "feature/backend-old", Position: 0, CreatedAt: now, + })) + require.NoError(t, testRepo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "wt-unknown-frontend", SessionID: "s-multi-unknown", WorktreeID: "worktree-unknown-frontend", RepositoryID: "repo-unknown-frontend", + WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "feature/frontend-old", Position: 1, CreatedAt: now, + })) + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + svc.handleBranchSwitched(ctx, watcher.GitEventData{ + TaskID: "t-multi-unknown", SessionID: "s-multi-unknown", + BranchSwitch: &lifecycle.GitBranchSwitchData{ + PreviousBranch: "feature/old", CurrentBranch: "feature/new", RepositoryName: "unknown-repository", + }, + }) + + worktrees, err := testRepo.ListTaskSessionWorktrees(ctx, "s-multi-unknown") + require.NoError(t, err) + branches := make(map[string]string, len(worktrees)) + for _, worktree := range worktrees { + branches[worktree.RepositoryID] = worktree.WorktreeBranch + } + require.Equal(t, "feature/backend-old", branches["repo-unknown-backend"]) + require.Equal(t, "feature/frontend-old", branches["repo-unknown-frontend"]) +} + // Regression: when a PR watch already exists for the session and the branch // is switched, the watch must be reset (branch updated, pr_number cleared) so // the poller re-searches for the PR on the new branch. This covers both diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index f39556aa37..2d3b4ddaf5 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -331,6 +331,11 @@ type Service struct { // session.ensure). Nil = unscoped. taskAccessCheck func(ctx context.Context, taskID string) error + // titleBranchRuntime performs the lifecycle-owned Git branch rename after + // an agent resolves a prompt-first task title. It is optional for tests and + // installations that do not configure an agent runtime. + titleBranchRuntime titleBranchRuntime + // Workflow step getter for prompt building workflowStepGetter WorkflowStepGetter diff --git a/apps/backend/internal/orchestrator/task_title_branch.go b/apps/backend/internal/orchestrator/task_title_branch.go new file mode 100644 index 0000000000..31b22b7159 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_title_branch.go @@ -0,0 +1,86 @@ +package orchestrator + +import ( + "fmt" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// TitleBranchRename is one successful or already-satisfied generated-branch +// outcome returned by the title handoff. +type TitleBranchRename struct { + RepositoryID string `json:"repository_id"` + From string `json:"from"` + To string `json:"to"` +} + +// TitleBranchPreservation records a repository that intentionally keeps its +// current branch because Kandev does not own that checkout. +type TitleBranchPreservation struct { + RepositoryID string `json:"repository_id"` + Branch string `json:"branch"` + Reason string `json:"reason"` +} + +// TitleBranchFailure records a generated-branch rename that could not be +// completed after the task title itself was accepted. +type TitleBranchFailure struct { + RepositoryID string `json:"repository_id"` + Branch string `json:"branch"` + Message string `json:"message"` +} + +const ( + TitleBranchStatusRenamed = "renamed" + TitleBranchStatusPreserved = "preserved" + TitleBranchStatusPartial = "partial" + TitleBranchStatusFailed = "failed" + TitleBranchStatusNotApplicable = "not_applicable" +) + +// TitleBranchRenameResult is the branch side-effect portion of an accepted +// generated title update. It deliberately reports partial Git outcomes rather +// than making title persistence appear to have failed. +type TitleBranchRenameResult struct { + Status string `json:"status"` + Renamed []TitleBranchRename `json:"renamed,omitempty"` + Preserved []TitleBranchPreservation `json:"preserved,omitempty"` + Failed []TitleBranchFailure `json:"failed,omitempty"` +} + +func renderTitleBranchName(title string, task *models.Task, repository *models.Repository, suffix string) (string, error) { + if task == nil { + return "", fmt.Errorf("task is required") + } + if repository == nil { + return "", fmt.Errorf("repository is required") + } + return worktree.RenderTaskBranchName(worktree.BranchNameTemplateInput{ + Template: repository.WorktreeBranchTemplate, + TaskID: task.ID, + Title: title, + Ticket: worktree.TicketForBranchName(task.Identifier, task.Metadata), + Suffix: suffix, + }) +} + +func aggregateTitleBranchRenameStatus( + renamed []TitleBranchRename, + preserved []TitleBranchPreservation, + failed []TitleBranchFailure, +) string { + if len(renamed) == 0 && len(preserved) == 0 && len(failed) == 0 { + return TitleBranchStatusNotApplicable + } + if len(failed) == 0 { + if len(renamed) > 0 { + return TitleBranchStatusRenamed + } + return TitleBranchStatusPreserved + } + if len(renamed) > 0 { + return TitleBranchStatusPartial + } + return TitleBranchStatusFailed +} diff --git a/apps/backend/internal/orchestrator/task_title_branch_runtime.go b/apps/backend/internal/orchestrator/task_title_branch_runtime.go new file mode 100644 index 0000000000..9740880774 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_title_branch_runtime.go @@ -0,0 +1,588 @@ +package orchestrator + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "path/filepath" + "strings" + + runtimeapi "github.com/kandev/kandev/internal/agent/runtime" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// titleBranchRuntime is intentionally narrower than the full agent manager +// interface. Branch renames are workspace operations owned by lifecycle, so +// adding this seam does not force every runtime test double to implement a new +// method. +type titleBranchRuntime interface { + RenameBranchForSession(ctx context.Context, sessionID, newName, repo string) (*runtimeapi.GitOperationResult, error) +} + +type titleBranchPrimaryRuntime interface { + RenameBranchForSessionWithPrimary(ctx context.Context, sessionID, newName, repo string, primary bool) (*runtimeapi.GitOperationResult, error) +} + +// titleBranchStore contains the repository reads and per-repository snapshot +// writes needed by the title handoff. The production SQLite repository +// implements this superset; keeping it optional preserves the smaller +// orchestrator test repositories. +type titleBranchStore interface { + ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) + GetRepository(ctx context.Context, id string) (*models.Repository, error) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) + UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error + UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error +} + +type titleBranchScopedSnapshotStore interface { + UpdateTaskSessionWorktreeBranchByRepository(ctx context.Context, sessionID, repositoryID, branch string) error +} + +type titleBranchWorktreeSnapshotStore interface { + UpdateTaskSessionWorktreeBranchByWorktree(ctx context.Context, sessionID, worktreeID, branch string) error +} + +type titleBranchWorktreeLister interface { + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) +} + +type titleBranchBinding struct { + taskRepository *models.TaskRepository + repository *models.Repository + worktree *models.TaskSessionWorktree + environmentRepo *models.TaskEnvironmentRepo + plan worktree.BranchIdentityPlan +} + +// SetTitleBranchRuntime wires the lifecycle-owned branch operation used by +// RenameGeneratedBranchesForTaskTitle. Nil disables Git side effects while the +// accepted title response remains successful. +func (s *Service) SetTitleBranchRuntime(runtime titleBranchRuntime) { + s.titleBranchRuntime = runtime +} + +// RenameGeneratedBranchesForTaskTitle updates Kandev-owned branches after the +// title-owner session resolves a prompt-first title. The title write has +// already succeeded before this method is called; branch failures therefore +// remain per-repository outcomes and never roll the title back. +func (s *Service) RenameGeneratedBranchesForTaskTitle( + ctx context.Context, + taskID string, + sessionID string, + title string, +) (TitleBranchRenameResult, error) { + var result TitleBranchRenameResult + + // This must remain the first operation for a task+session entry point. + if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil { + return result, err + } + if s.repo == nil { + return result, fmt.Errorf("orchestrator repository is not configured") + } + + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return result, err + } + store, ok := s.repo.(titleBranchStore) + if !ok { + return result, fmt.Errorf("title branch repository capabilities are not configured") + } + worktrees, err := store.ListTaskSessionWorktrees(ctx, sessionID) + if err != nil { + return result, err + } + if len(worktrees) == 0 { + result.Status = TitleBranchStatusNotApplicable + return result, nil + } + taskRepositories, err := store.ListTaskRepositories(ctx, taskID) + if err != nil { + return result, err + } + if len(taskRepositories) == 0 { + return missingTitleBranchRepositoryResult(worktrees), nil + } + + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return result, err + } + env, envRepos, err := s.titleBranchEnvironmentState(ctx, session, taskID, store) + if err != nil { + return result, err + } + + executorType := s.titleBranchExecutorType(ctx, session, env) + bindings, err := s.buildTitleBranchBindings(ctx, store, taskRepositories, worktrees, envRepos) + if err != nil { + return result, err + } + return s.renameTitleBranches(ctx, task, title, executorType, env, worktrees, bindings), nil +} + +func missingTitleBranchRepositoryResult(worktrees []*models.TaskSessionWorktree) TitleBranchRenameResult { + var result TitleBranchRenameResult + for _, worktree := range worktrees { + if worktree == nil { + continue + } + result.Failed = append(result.Failed, TitleBranchFailure{ + RepositoryID: worktree.RepositoryID, + Branch: worktree.WorktreeBranch, + Message: "task repository metadata is unavailable", + }) + } + result.Status = aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed) + return result +} + +func (s *Service) renameTitleBranches( + ctx context.Context, + task *models.Task, + title string, + executorType models.ExecutorType, + env *models.TaskEnvironment, + worktrees []*models.TaskSessionWorktree, + bindings []titleBranchBinding, +) TitleBranchRenameResult { + var result TitleBranchRenameResult + multiRepo := len(bindings) > 1 || len(worktrees) > 1 + for _, binding := range bindings { + s.renameTitleBranchBinding(ctx, task, title, executorType, env, multiRepo, binding, &result) + } + result.Status = aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed) + return result +} + +func (s *Service) titleBranchEnvironment(ctx context.Context, session *models.TaskSession, taskID string) (*models.TaskEnvironment, error) { + if session != nil && session.TaskEnvironmentID != "" { + return s.repo.GetTaskEnvironment(ctx, session.TaskEnvironmentID) + } + return s.repo.GetTaskEnvironmentByTaskID(ctx, taskID) +} + +func (s *Service) titleBranchEnvironmentState( + ctx context.Context, + session *models.TaskSession, + taskID string, + store titleBranchStore, +) (*models.TaskEnvironment, map[string][]*models.TaskEnvironmentRepo, error) { + env, err := s.titleBranchEnvironment(ctx, session, taskID) + if err != nil || env == nil { + return env, nil, err + } + rows := env.Repos + if len(rows) == 0 && env.ID != "" { + rows, err = store.ListTaskEnvironmentRepos(ctx, env.ID) + if err != nil { + return nil, nil, err + } + } + envRepos := make(map[string][]*models.TaskEnvironmentRepo, len(rows)) + for _, envRepo := range rows { + if envRepo == nil { + continue + } + envRepos[envRepo.RepositoryID] = append(envRepos[envRepo.RepositoryID], envRepo) + } + return env, envRepos, nil +} + +func (s *Service) titleBranchExecutorType(ctx context.Context, session *models.TaskSession, env *models.TaskEnvironment) models.ExecutorType { + if env != nil && env.ExecutorType != "" { + return models.ExecutorType(env.ExecutorType) + } + if session != nil && session.ExecutorID != "" { + if executor, err := s.repo.GetExecutor(ctx, session.ExecutorID); err == nil && executor != nil && executor.Type != "" { + return executor.Type + } + } + // Historical rows without an environment were host worktrees. Retain that + // naming strategy rather than inventing a deterministic remote suffix. + return models.ExecutorTypeWorktree +} + +func (s *Service) buildTitleBranchBindings( + ctx context.Context, + store titleBranchStore, + taskRepositories []*models.TaskRepository, + worktrees []*models.TaskSessionWorktree, + envRepos map[string][]*models.TaskEnvironmentRepo, +) ([]titleBranchBinding, error) { + inputs := make([]worktree.BranchIdentityInput, 0, len(taskRepositories)) + bindings := make([]titleBranchBinding, 0, len(taskRepositories)) + for _, taskRepository := range taskRepositories { + if taskRepository == nil { + continue + } + repository, err := store.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return nil, err + } + if repository == nil { + return nil, fmt.Errorf("repository %q is unavailable", taskRepository.RepositoryID) + } + bindings = append(bindings, titleBranchBinding{ + taskRepository: taskRepository, + repository: repository, + }) + inputs = append(inputs, worktree.BranchIdentityInput{ + RepositoryID: taskRepository.RepositoryID, + BaseBranch: taskRepository.BaseBranch, + CheckoutBranch: taskRepository.CheckoutBranch, + DefaultBranch: repository.DefaultBranch, + PRNumber: titleBranchPRNumber(taskRepository.Metadata), + Position: taskRepository.Position, + }) + } + plans := worktree.BuildBranchIdentityPlans(inputs) + for i := range bindings { + repositoryID := bindings[i].taskRepository.RepositoryID + bindings[i].plan = plans[i] + bindings[i].worktree = titleBranchWorktree(worktrees, repositoryID, plans[i].IdentitySlug) + bindings[i].environmentRepo = titleBranchEnvironmentRepo(envRepos[repositoryID], plans[i].IdentitySlug) + } + return bindings, nil +} + +func titleBranchWorktree(worktrees []*models.TaskSessionWorktree, repositoryID, branchSlug string) *models.TaskSessionWorktree { + var fallback *models.TaskSessionWorktree + for _, sessionWorktree := range worktrees { + if sessionWorktree == nil || sessionWorktree.RepositoryID != repositoryID { + continue + } + if fallback == nil { + fallback = sessionWorktree + } + if worktree.SanitizeBranchSlug(sessionWorktree.BranchSlug) == branchSlug { + return sessionWorktree + } + } + return fallback +} + +func titleBranchEnvironmentRepo(repos []*models.TaskEnvironmentRepo, branchSlug string) *models.TaskEnvironmentRepo { + var fallback *models.TaskEnvironmentRepo + for _, repo := range repos { + if repo == nil { + continue + } + if fallback == nil { + fallback = repo + } + if worktree.SanitizeBranchSlug(repo.BranchSlug) == branchSlug { + return repo + } + } + return fallback +} + +func addTitleBranchPreservation(result *TitleBranchRenameResult, repositoryID, branch, reason string) { + result.Preserved = append(result.Preserved, TitleBranchPreservation{ + RepositoryID: repositoryID, + Branch: branch, + Reason: reason, + }) +} + +func addTitleBranchFailure(result *TitleBranchRenameResult, repositoryID, branch, message string) { + result.Failed = append(result.Failed, TitleBranchFailure{ + RepositoryID: repositoryID, + Branch: branch, + Message: message, + }) +} + +func (s *Service) renameTitleBranchBinding( + ctx context.Context, + task *models.Task, + title string, + executorType models.ExecutorType, + env *models.TaskEnvironment, + multiRepo bool, + binding titleBranchBinding, + result *TitleBranchRenameResult, +) { + if binding.taskRepository == nil || binding.repository == nil { + return + } + branch := titleBranchCurrentBranch(binding, env) + if reason := titleBranchPreservationReason(binding.taskRepository, executorType); reason != "" { + addTitleBranchPreservation(result, binding.taskRepository.RepositoryID, branch, reason) + return + } + if binding.worktree == nil || branch == "" { + addTitleBranchFailure(result, binding.taskRepository.RepositoryID, branch, "current generated branch is unavailable") + return + } + generatedBranch, generatedBranchKnown := titleBranchGeneratedSnapshot(binding, env) + if !generatedBranchKnown { + addTitleBranchPreservation(result, binding.taskRepository.RepositoryID, branch, "branch_unverified") + return + } + if generatedBranch != branch { + addTitleBranchPreservation(result, binding.taskRepository.RepositoryID, branch, "switched_branch") + return + } + if s.titleBranchRuntime == nil { + addTitleBranchPreservation(result, binding.taskRepository.RepositoryID, branch, "runtime_unavailable") + return + } + + primary := titleBranchIsPrimaryEnvironment(env, binding, multiRepo) + newName, err := s.performTitleBranchRename(ctx, task, title, executorType, env, multiRepo, primary, binding, branch) + if err != nil { + addTitleBranchFailure(result, binding.taskRepository.RepositoryID, branch, err.Error()) + return + } + if branch == newName { + result.Renamed = append(result.Renamed, TitleBranchRename{ + RepositoryID: binding.taskRepository.RepositoryID, + From: branch, + To: newName, + }) + return + } + if err := s.persistTitleBranchSnapshots(ctx, env, binding, newName, multiRepo); err != nil { + addTitleBranchFailure(result, binding.taskRepository.RepositoryID, newName, err.Error()) + return + } + result.Renamed = append(result.Renamed, TitleBranchRename{ + RepositoryID: binding.taskRepository.RepositoryID, + From: branch, + To: newName, + }) +} + +func (s *Service) performTitleBranchRename( + ctx context.Context, + task *models.Task, + title string, + executorType models.ExecutorType, + env *models.TaskEnvironment, + multiRepo bool, + primary bool, + binding titleBranchBinding, + branch string, +) (string, error) { + suffix := titleBranchSuffix(executorType, task.ID) + newName, err := renderTitleBranchName(title, task, binding.repository, suffix) + if err != nil { + return "", err + } + if branch == newName { + return newName, nil + } + repoPath := titleBranchAgentctlRepoPath(env, binding, multiRepo) + operation, err := s.renameTitleBranchRuntime(ctx, binding.worktree.SessionID, newName, repoPath, primary) + if err != nil { + return "", err + } + if operation == nil || !operation.Success { + if operation != nil && operation.Error != "" { + return "", fmt.Errorf("%s", operation.Error) + } + return "", fmt.Errorf("agentctl rejected branch rename") + } + return newName, nil +} + +func (s *Service) renameTitleBranchRuntime(ctx context.Context, sessionID, newName, repoPath string, primary bool) (*runtimeapi.GitOperationResult, error) { + if scoped, ok := s.titleBranchRuntime.(titleBranchPrimaryRuntime); ok { + return scoped.RenameBranchForSessionWithPrimary(ctx, sessionID, newName, repoPath, primary) + } + return s.titleBranchRuntime.RenameBranchForSession(ctx, sessionID, newName, repoPath) +} + +func titleBranchPreservationReason(taskRepository *models.TaskRepository, executorType models.ExecutorType) string { + if taskRepository != nil && taskRepository.CheckoutBranch != "" { + return "remote_checkout" + } + if executorType == models.ExecutorTypeLocal || string(executorType) == "local_pc" { + return "local_executor" + } + return "" +} + +func titleBranchCurrentBranch(binding titleBranchBinding, env *models.TaskEnvironment) string { + if binding.worktree != nil && binding.worktree.WorktreeBranch != "" { + return binding.worktree.WorktreeBranch + } + if binding.environmentRepo != nil && binding.environmentRepo.WorktreeBranch != "" { + return binding.environmentRepo.WorktreeBranch + } + if env != nil && env.WorktreeBranch != "" { + return env.WorktreeBranch + } + return "" +} + +func titleBranchSuffix(executorType models.ExecutorType, taskID string) string { + if executorType == models.ExecutorTypeWorktree { + // The initial worktree suffix is random, but title handoffs can be + // retried. Derive a stable replacement from task data so repeated + // renders of the same title remain idempotent. + digest := sha256.Sum256([]byte(taskID)) + return hex.EncodeToString(digest[:])[:3] + } + suffix := taskID + if len(suffix) > 6 { + suffix = suffix[:6] + } + return suffix +} + +func titleBranchAgentctlRepoPath(env *models.TaskEnvironment, binding titleBranchBinding, multiRepo bool) string { + if !multiRepo || binding.worktree == nil { + return "" + } + if env != nil && env.WorkspacePath != "" && binding.worktree.WorktreePath != "" { + if relative, err := filepath.Rel(env.WorkspacePath, binding.worktree.WorktreePath); err == nil && relative != "." && !strings.HasPrefix(relative, "..") { + return filepath.ToSlash(relative) + } + } + repoName := worktree.SanitizeRepoDirName(binding.repository.Name) + if repoName == "" { + repoName = worktree.SanitizeRepoDirName(binding.repository.ID) + } + if slug := worktree.SanitizeBranchSlug(binding.plan.PathSlug); slug != "" { + repoName += "-" + slug + } + return repoName +} + +func (s *Service) persistTitleBranchSnapshots( + ctx context.Context, + env *models.TaskEnvironment, + binding titleBranchBinding, + branch string, + multiRepo bool, +) error { + if err := s.persistTitleBranchSessionWorktreeSnapshot(ctx, binding, branch, multiRepo); err != nil { + return err + } + if err := s.persistTitleBranchEnvironmentRepoSnapshot(ctx, binding, branch); err != nil { + return err + } + return s.persistTitleBranchLegacyEnvironmentSnapshot(ctx, env, binding, branch, multiRepo) +} + +func (s *Service) persistTitleBranchSessionWorktreeSnapshot(ctx context.Context, binding titleBranchBinding, branch string, multiRepo bool) error { + if binding.worktree == nil { + return nil + } + binding.worktree.WorktreeBranch = branch + if exact, ok := s.repo.(titleBranchWorktreeSnapshotStore); ok && binding.worktree.WorktreeID != "" { + if err := exact.UpdateTaskSessionWorktreeBranchByWorktree(ctx, binding.worktree.SessionID, binding.worktree.WorktreeID, branch); err != nil { + return fmt.Errorf("failed to persist session worktree branch: %w", err) + } + return nil + } + if scoped, ok := s.repo.(titleBranchScopedSnapshotStore); ok { + if err := scoped.UpdateTaskSessionWorktreeBranchByRepository(ctx, binding.worktree.SessionID, binding.worktree.RepositoryID, branch); err != nil { + return fmt.Errorf("failed to persist session worktree branch: %w", err) + } + return nil + } + if !multiRepo { + if err := s.repo.UpdateTaskSessionWorktreeBranch(ctx, binding.worktree.SessionID, branch); err != nil { + return fmt.Errorf("failed to persist session worktree branch: %w", err) + } + return nil + } + return fmt.Errorf("repository-scoped session worktree snapshot capability is unavailable") +} + +func (s *Service) persistTitleBranchEnvironmentRepoSnapshot(ctx context.Context, binding titleBranchBinding, branch string) error { + if binding.environmentRepo == nil { + return nil + } + binding.environmentRepo.WorktreeBranch = branch + store, ok := s.repo.(titleBranchStore) + if !ok { + return nil + } + if err := store.UpdateTaskEnvironmentRepo(ctx, binding.environmentRepo); err != nil { + return fmt.Errorf("failed to persist environment repository branch: %w", err) + } + return nil +} + +func (s *Service) persistTitleBranchLegacyEnvironmentSnapshot( + ctx context.Context, + env *models.TaskEnvironment, + binding titleBranchBinding, + branch string, + multiRepo bool, +) error { + if env == nil || binding.taskRepository == nil || !titleBranchIsPrimaryEnvironment(env, binding, multiRepo) || env.WorktreeBranch == branch { + return nil + } + env.WorktreeBranch = branch + store, ok := s.repo.(titleBranchStore) + if !ok { + return nil + } + if err := store.UpdateTaskEnvironment(ctx, env); err != nil { + return fmt.Errorf("failed to persist environment branch: %w", err) + } + return nil +} + +func titleBranchIsPrimaryEnvironment(env *models.TaskEnvironment, binding titleBranchBinding, multiRepo bool) bool { + if !multiRepo { + return true + } + if env == nil { + return binding.taskRepository != nil && binding.taskRepository.Position == 0 + } + if env.WorktreeID != "" && binding.worktree != nil && env.WorktreeID == binding.worktree.WorktreeID { + return true + } + if env.WorktreeID != "" { + return false + } + if env.RepositoryID != "" && binding.taskRepository != nil && env.RepositoryID == binding.taskRepository.RepositoryID { + return binding.plan.PathSlug == "" + } + return binding.taskRepository != nil && binding.taskRepository.Position == 0 +} + +func titleBranchGeneratedSnapshot(binding titleBranchBinding, env *models.TaskEnvironment) (string, bool) { + if binding.environmentRepo != nil && binding.environmentRepo.WorktreeBranch != "" { + return binding.environmentRepo.WorktreeBranch, true + } + if env != nil && binding.taskRepository != nil && titleBranchIsPrimaryEnvironment(env, binding, true) { + if env.WorktreeBranch != "" { + return env.WorktreeBranch, true + } + } + return "", false +} + +func titleBranchPRNumber(metadata map[string]interface{}) int { + if metadata == nil { + return 0 + } + switch value := metadata["pr_number"].(type) { + case int: + if value > 0 { + return value + } + case int64: + if value > 0 { + return int(value) + } + case float64: + if value > 0 { + return int(value) + } + } + return 0 +} diff --git a/apps/backend/internal/orchestrator/task_title_branch_service_test.go b/apps/backend/internal/orchestrator/task_title_branch_service_test.go new file mode 100644 index 0000000000..5330a807d4 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_title_branch_service_test.go @@ -0,0 +1,411 @@ +package orchestrator + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/stretchr/testify/require" +) + +type titleBranchRuntimeStub struct { + calls []titleBranchRuntimeCall + primaryCalls []bool + err error +} + +type titleBranchRuntimeCall struct { + sessionID string + newName string + repo string +} + +type failingTitleBranchSnapshotStore struct { + *sqliterepo.Repository + err error +} + +func (s *failingTitleBranchSnapshotStore) UpdateTaskSessionWorktreeBranchByWorktree(context.Context, string, string, string) error { + return s.err +} + +func (s *titleBranchRuntimeStub) RenameBranchForSession(_ context.Context, sessionID, newName, repo string) (*agentctlclient.GitOperationResult, error) { + s.calls = append(s.calls, titleBranchRuntimeCall{sessionID: sessionID, newName: newName, repo: repo}) + if s.err != nil { + return nil, s.err + } + return &agentctlclient.GitOperationResult{Success: true, Operation: "rename_branch"}, nil +} + +func (s *titleBranchRuntimeStub) RenameBranchForSessionWithPrimary(ctx context.Context, sessionID, newName, repo string, primary bool) (*agentctlclient.GitOperationResult, error) { + s.primaryCalls = append(s.primaryCalls, primary) + return s.RenameBranchForSession(ctx, sessionID, newName, repo) +} + +func TestRenameGeneratedBranchesForTaskTitleUsesFinalTitleAndPersistsSnapshots(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-title-branch", "session-title-branch", "step1") + taskRoot := t.TempDir() + now := time.Now().UTC() + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-title-branch", WorkspaceID: "ws1", Name: "backend", DefaultBranch: "main", + WorktreeBranchTemplate: "feature/{title}", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-title-branch", TaskID: "task-title-branch", RepositoryID: "repo-title-branch", + BaseBranch: "main", Position: 0, + })) + require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-title-branch", TaskID: "task-title-branch", RepositoryID: "repo-title-branch", + ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-title-branch", RepositoryID: "repo-title-branch", BranchSlug: "main", + WorktreeID: "wt-title-branch", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "feature/provisional", Position: 0, + }}, + })) + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-wt-title-branch", SessionID: "session-title-branch", WorktreeID: "wt-title-branch", + RepositoryID: "repo-title-branch", BranchSlug: "main", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "feature/provisional", Position: 0, CreatedAt: now, + })) + session, err := repo.GetTaskSession(ctx, "session-title-branch") + require.NoError(t, err) + session.TaskEnvironmentID = "env-title-branch" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + runtime := &titleBranchRuntimeStub{} + service.SetTitleBranchRuntime(runtime) + + result, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-title-branch", "session-title-branch", "Final title") + require.NoError(t, err) + require.Equal(t, TitleBranchStatusRenamed, result.Status) + require.Len(t, result.Renamed, 1) + require.Equal(t, "feature/final-title", result.Renamed[0].To) + require.Equal(t, []titleBranchRuntimeCall{{ + sessionID: "session-title-branch", newName: "feature/final-title", repo: "", + }}, runtime.calls) + require.Equal(t, []bool{true}, runtime.primaryCalls) + + worktrees, err := repo.ListTaskSessionWorktrees(ctx, "session-title-branch") + require.NoError(t, err) + require.Equal(t, "feature/final-title", worktrees[0].WorktreeBranch) + env, err := repo.GetTaskEnvironment(ctx, "env-title-branch") + require.NoError(t, err) + require.Equal(t, "feature/final-title", env.Repos[0].WorktreeBranch) +} + +func TestRenameGeneratedBranchesForTaskTitleUsesStableWorktreeSuffix(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-title-stable-suffix", "session-title-stable-suffix", "step1") + taskRoot := t.TempDir() + now := time.Now().UTC() + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-title-stable-suffix", WorkspaceID: "ws1", Name: "backend", DefaultBranch: "main", + WorktreeBranchTemplate: "feature/{title}-{suffix}", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-title-stable-suffix", TaskID: "task-title-stable-suffix", RepositoryID: "repo-title-stable-suffix", + BaseBranch: "main", Position: 0, + })) + require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-title-stable-suffix", TaskID: "task-title-stable-suffix", RepositoryID: "repo-title-stable-suffix", + ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-title-stable-suffix", RepositoryID: "repo-title-stable-suffix", BranchSlug: "main", + WorktreeID: "wt-title-stable-suffix", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "feature/provisional-abc", Position: 0, + }}, + })) + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-wt-title-stable-suffix", SessionID: "session-title-stable-suffix", WorktreeID: "wt-title-stable-suffix", + RepositoryID: "repo-title-stable-suffix", BranchSlug: "main", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "feature/provisional-abc", Position: 0, CreatedAt: now, + })) + session, err := repo.GetTaskSession(ctx, "session-title-stable-suffix") + require.NoError(t, err) + session.TaskEnvironmentID = "env-title-stable-suffix" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + runtime := &titleBranchRuntimeStub{} + service.SetTitleBranchRuntime(runtime) + first, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-title-stable-suffix", "session-title-stable-suffix", "Final title") + require.NoError(t, err) + require.Len(t, first.Renamed, 1) + second, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-title-stable-suffix", "session-title-stable-suffix", "Final title") + require.NoError(t, err) + require.Len(t, second.Renamed, 1) + require.Equal(t, first.Renamed[0].To, second.Renamed[0].To) + require.Len(t, runtime.calls, 1, "the second identical handoff should be idempotent") +} + +func TestRenameGeneratedBranchesForTaskTitlePreservesManuallySelectedBranch(t *testing.T) { + runtime := &titleBranchRuntimeStub{} + service := &Service{titleBranchRuntime: runtime} + result := TitleBranchRenameResult{} + service.renameTitleBranchBinding( + context.Background(), + &models.Task{ID: "task-manual-branch", Title: "Final title"}, + "Final title", + models.ExecutorTypeWorktree, + &models.TaskEnvironment{Repos: []*models.TaskEnvironmentRepo{{WorktreeBranch: "feature/provisional"}}}, + false, + titleBranchBinding{ + taskRepository: &models.TaskRepository{RepositoryID: "repo-manual-branch"}, + repository: &models.Repository{ID: "repo-manual-branch", WorktreeBranchTemplate: "feature/{title}"}, + worktree: &models.TaskSessionWorktree{SessionID: "session-manual-branch", RepositoryID: "repo-manual-branch", WorktreeBranch: "feature/user-selected"}, + environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-manual-branch", WorktreeBranch: "feature/provisional"}, + }, + &result, + ) + require.Equal(t, TitleBranchStatusPreserved, aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed)) + require.Equal(t, "switched_branch", result.Preserved[0].Reason) + require.Empty(t, runtime.calls) +} + +func TestRenameGeneratedBranchesForTaskTitlePreservesRemoteCheckout(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-remote-branch", "session-remote-branch", "step1") + taskRoot := t.TempDir() + now := time.Now().UTC() + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-remote-branch", WorkspaceID: "ws1", Name: "backend", DefaultBranch: "main", + WorktreeBranchTemplate: "feature/{title}", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-remote-branch", TaskID: "task-remote-branch", RepositoryID: "repo-remote-branch", + BaseBranch: "main", CheckoutBranch: "pr-42", Position: 0, + })) + require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-remote-branch", TaskID: "task-remote-branch", RepositoryID: "repo-remote-branch", + ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-remote-branch", RepositoryID: "repo-remote-branch", BranchSlug: "pr-42", + WorktreeID: "wt-remote-branch", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "pr-42", Position: 0, + }}, + })) + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-wt-remote-branch", SessionID: "session-remote-branch", WorktreeID: "wt-remote-branch", + RepositoryID: "repo-remote-branch", BranchSlug: "pr-42", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "pr-42", Position: 0, CreatedAt: now, + })) + session, err := repo.GetTaskSession(ctx, "session-remote-branch") + require.NoError(t, err) + session.TaskEnvironmentID = "env-remote-branch" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + runtime := &titleBranchRuntimeStub{} + service.SetTitleBranchRuntime(runtime) + + result, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-remote-branch", "session-remote-branch", "Final title") + require.NoError(t, err) + require.Equal(t, TitleBranchStatusPreserved, result.Status) + require.Len(t, result.Preserved, 1) + require.Equal(t, "remote_checkout", result.Preserved[0].Reason) + require.Empty(t, runtime.calls) +} + +func TestRenameGeneratedBranchesForTaskTitleScopesMixedRepositories(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-mixed-branches", "session-mixed-branches", "step1") + taskRoot := t.TempDir() + now := time.Now().UTC() + for _, repository := range []*models.Repository{ + {ID: "repo-mixed-backend", WorkspaceID: "ws1", Name: "backend", DefaultBranch: "main", WorktreeBranchTemplate: "feature/{title}", CreatedAt: now, UpdatedAt: now}, + {ID: "repo-mixed-frontend", WorkspaceID: "ws1", Name: "frontend", DefaultBranch: "main", WorktreeBranchTemplate: "feature/{title}", CreatedAt: now, UpdatedAt: now}, + } { + require.NoError(t, repo.CreateRepository(ctx, repository)) + } + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-mixed-backend", TaskID: "task-mixed-branches", RepositoryID: "repo-mixed-backend", BaseBranch: "main", Position: 0, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-mixed-frontend", TaskID: "task-mixed-branches", RepositoryID: "repo-mixed-frontend", BaseBranch: "main", CheckoutBranch: "pr-42", Position: 1, + })) + require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-mixed-branches", TaskID: "task-mixed-branches", ExecutorType: string(models.ExecutorTypeWorktree), + WorkspacePath: taskRoot, Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{ + {ID: "env-repo-mixed-backend", RepositoryID: "repo-mixed-backend", BranchSlug: "main", WorktreeID: "wt-mixed-backend", WorktreePath: filepath.Join(taskRoot, "backend"), WorktreeBranch: "feature/provisional-backend", Position: 0}, + {ID: "env-repo-mixed-frontend", RepositoryID: "repo-mixed-frontend", BranchSlug: "pr-42", WorktreeID: "wt-mixed-frontend", WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "pr-42", Position: 1}, + }, + })) + for _, worktree := range []*models.TaskSessionWorktree{ + {ID: "session-wt-mixed-backend", SessionID: "session-mixed-branches", WorktreeID: "wt-mixed-backend", RepositoryID: "repo-mixed-backend", BranchSlug: "main", WorktreePath: filepath.Join(taskRoot, "backend"), WorktreeBranch: "feature/provisional-backend", Position: 0, CreatedAt: now}, + {ID: "session-wt-mixed-frontend", SessionID: "session-mixed-branches", WorktreeID: "wt-mixed-frontend", RepositoryID: "repo-mixed-frontend", BranchSlug: "pr-42", WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "pr-42", Position: 1, CreatedAt: now}, + } { + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, worktree)) + } + session, err := repo.GetTaskSession(ctx, "session-mixed-branches") + require.NoError(t, err) + session.TaskEnvironmentID = "env-mixed-branches" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + runtime := &titleBranchRuntimeStub{} + service.SetTitleBranchRuntime(runtime) + result, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-mixed-branches", "session-mixed-branches", "Final title") + require.NoError(t, err) + require.Equal(t, TitleBranchStatusRenamed, result.Status) + require.Len(t, result.Renamed, 1) + require.Equal(t, "repo-mixed-backend", result.Renamed[0].RepositoryID) + require.Len(t, result.Preserved, 1) + require.Equal(t, "repo-mixed-frontend", result.Preserved[0].RepositoryID) + require.Equal(t, []titleBranchRuntimeCall{{sessionID: "session-mixed-branches", newName: "feature/final-title", repo: "backend"}}, runtime.calls) + require.Equal(t, []bool{true}, runtime.primaryCalls) +} + +func TestRenameGeneratedBranchesForTaskTitleScopesOwnerSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task-owner-scope", "session-owner-scope", "step1") + now := time.Now().UTC() + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-non-owner-scope", TaskID: "task-owner-scope", State: models.TaskSessionStateRunning, + StartedAt: now, UpdatedAt: now, + })) + taskRoot := t.TempDir() + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-owner-scope", WorkspaceID: "ws1", Name: "backend", DefaultBranch: "main", + WorktreeBranchTemplate: "feature/{title}", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-owner-scope", TaskID: "task-owner-scope", RepositoryID: "repo-owner-scope", + BaseBranch: "main", Position: 0, + })) + require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-owner-scope", TaskID: "task-owner-scope", RepositoryID: "repo-owner-scope", + ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-owner-scope", RepositoryID: "repo-owner-scope", BranchSlug: "main", + WorktreeID: "worktree-owner-scope", WorktreePath: filepath.Join(taskRoot, "backend"), + WorktreeBranch: "feature/owner-provisional", Position: 0, + }}, + })) + ownerSession, err := repo.GetTaskSession(ctx, "session-owner-scope") + require.NoError(t, err) + ownerSession.TaskEnvironmentID = "env-owner-scope" + require.NoError(t, repo.UpdateTaskSession(ctx, ownerSession)) + for _, worktree := range []*models.TaskSessionWorktree{ + {ID: "session-wt-owner-scope", SessionID: "session-owner-scope", WorktreeID: "worktree-owner-scope", RepositoryID: "repo-owner-scope", BranchSlug: "main", WorktreePath: filepath.Join(taskRoot, "backend"), WorktreeBranch: "feature/owner-provisional", Position: 0, CreatedAt: now}, + {ID: "session-wt-non-owner-scope", SessionID: "session-non-owner-scope", WorktreeID: "worktree-non-owner-scope", RepositoryID: "repo-owner-scope", BranchSlug: "main", WorktreePath: filepath.Join(taskRoot, "backend-non-owner"), WorktreeBranch: "feature/non-owner-provisional", Position: 0, CreatedAt: now}, + } { + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, worktree)) + } + + service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + runtime := &titleBranchRuntimeStub{} + service.SetTitleBranchRuntime(runtime) + result, err := service.RenameGeneratedBranchesForTaskTitle(ctx, "task-owner-scope", "session-owner-scope", "Final title") + require.NoError(t, err) + require.Equal(t, TitleBranchStatusRenamed, result.Status) + require.Equal(t, []titleBranchRuntimeCall{{sessionID: "session-owner-scope", newName: "feature/final-title", repo: ""}}, runtime.calls) + + ownerWorktrees, err := repo.ListTaskSessionWorktrees(ctx, "session-owner-scope") + require.NoError(t, err) + require.Len(t, ownerWorktrees, 1) + require.Equal(t, "feature/final-title", ownerWorktrees[0].WorktreeBranch) + nonOwnerWorktrees, err := repo.ListTaskSessionWorktrees(ctx, "session-non-owner-scope") + require.NoError(t, err) + require.Len(t, nonOwnerWorktrees, 1) + require.Equal(t, "feature/non-owner-provisional", nonOwnerWorktrees[0].WorktreeBranch) +} + +func TestRenameGeneratedBranchesForTaskTitlePreservesLocalExecutor(t *testing.T) { + runtime := &titleBranchRuntimeStub{} + service := &Service{titleBranchRuntime: runtime} + result := TitleBranchRenameResult{} + service.renameTitleBranchBinding( + context.Background(), + &models.Task{ID: "task-local", Title: "Final title"}, + "Final title", + models.ExecutorTypeLocal, + nil, + false, + titleBranchBinding{ + taskRepository: &models.TaskRepository{RepositoryID: "repo-local"}, + repository: &models.Repository{ID: "repo-local", WorktreeBranchTemplate: "feature/{title}"}, + worktree: &models.TaskSessionWorktree{SessionID: "session-local", RepositoryID: "repo-local", WorktreeBranch: "user-branch"}, + }, + &result, + ) + require.Equal(t, TitleBranchStatusPreserved, aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed)) + require.Equal(t, "local_executor", result.Preserved[0].Reason) + require.Empty(t, runtime.calls) +} + +func TestRenameGeneratedBranchesForTaskTitleReportsGitRenameFailure(t *testing.T) { + runtime := &titleBranchRuntimeStub{err: errors.New("git branch rename failed")} + service := &Service{titleBranchRuntime: runtime} + result := TitleBranchRenameResult{} + service.renameTitleBranchBinding( + context.Background(), + &models.Task{ID: "task-snapshot-failure", Title: "Final title"}, + "Final title", + models.ExecutorTypeWorktree, + nil, + false, + titleBranchBinding{ + taskRepository: &models.TaskRepository{RepositoryID: "repo-snapshot-failure", Position: 0}, + repository: &models.Repository{ID: "repo-snapshot-failure", WorktreeBranchTemplate: "feature/{title}"}, + worktree: &models.TaskSessionWorktree{SessionID: "session-snapshot-failure", RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, + environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, + }, + &result, + ) + + require.Equal(t, TitleBranchStatusFailed, aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed)) + require.Len(t, result.Failed, 1) + require.Equal(t, "repo-snapshot-failure", result.Failed[0].RepositoryID) + require.Equal(t, "feature/provisional", result.Failed[0].Branch) + require.Contains(t, result.Failed[0].Message, "git branch rename failed") +} + +func TestRenameGeneratedBranchesForTaskTitleReportsSnapshotFailure(t *testing.T) { + store := &failingTitleBranchSnapshotStore{ + Repository: setupTestRepo(t), + err: errors.New("branch snapshot persistence failed"), + } + service := &Service{ + repo: store, + titleBranchRuntime: &titleBranchRuntimeStub{}, + } + result := TitleBranchRenameResult{} + service.renameTitleBranchBinding( + context.Background(), + &models.Task{ID: "task-snapshot-failure", Title: "Final title"}, + "Final title", + models.ExecutorTypeWorktree, + nil, + false, + titleBranchBinding{ + taskRepository: &models.TaskRepository{RepositoryID: "repo-snapshot-failure", Position: 0}, + repository: &models.Repository{ID: "repo-snapshot-failure", WorktreeBranchTemplate: "feature/{title}"}, + worktree: &models.TaskSessionWorktree{SessionID: "session-snapshot-failure", WorktreeID: "worktree-snapshot-failure", RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, + environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, + }, + &result, + ) + + require.Equal(t, TitleBranchStatusFailed, aggregateTitleBranchRenameStatus(result.Renamed, result.Preserved, result.Failed)) + require.Len(t, result.Failed, 1) + require.Equal(t, "repo-snapshot-failure", result.Failed[0].RepositoryID) + require.Equal(t, "feature/final-title", result.Failed[0].Branch) + require.Contains(t, result.Failed[0].Message, "branch snapshot persistence failed") +} diff --git a/apps/backend/internal/orchestrator/task_title_branch_test.go b/apps/backend/internal/orchestrator/task_title_branch_test.go new file mode 100644 index 0000000000..aaa9798631 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_title_branch_test.go @@ -0,0 +1,33 @@ +package orchestrator + +import ( + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestRenderTitleBranchNameUsesFinalTitleAndRepositoryTemplate(t *testing.T) { + got, err := renderTitleBranchName( + "Improve login flow", + &models.Task{ID: "task-123", Identifier: "KAN-42"}, + &models.Repository{WorktreeBranchTemplate: "feature/{ticket}-{title}-{suffix}"}, + "abc", + ) + if err != nil { + t.Fatalf("renderTitleBranchName returned error: %v", err) + } + if got != "feature/kan-42-improve-login-flow-abc" { + t.Fatalf("renderTitleBranchName = %q, want final-title branch", got) + } +} + +func TestTitleBranchRenameStatusDistinguishesPartialFailure(t *testing.T) { + result := aggregateTitleBranchRenameStatus( + []TitleBranchRename{{RepositoryID: "repo-a"}}, + []TitleBranchPreservation{{RepositoryID: "repo-b", Reason: "remote_checkout"}}, + []TitleBranchFailure{{RepositoryID: "repo-c", Message: "collision"}}, + ) + if result != TitleBranchStatusPartial { + t.Fatalf("aggregateTitleBranchRenameStatus = %q, want %q", result, TitleBranchStatusPartial) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 9b9effc89f..dc792c8a90 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -508,6 +508,40 @@ func (r *Repository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, return nil } +// UpdateExecutorRunningWorktreeBranch narrowly updates the branch snapshot for +// a live execution. The execution ID is part of the CAS so a rotated execution +// cannot overwrite the successor's branch snapshot. +func (r *Repository) UpdateExecutorRunningWorktreeBranch(ctx context.Context, sessionID, expectedExecID, branch string) error { + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + if expectedExecID == "" { + return fmt.Errorf("expected execution_id is required") + } + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE executors_running + SET worktree_branch = ?, updated_at = ? + WHERE session_id = ? + AND agent_execution_id = ? + `), branch, now, sessionID, expectedExecID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + exists, hasErr := r.HasExecutorRunningRow(ctx, sessionID) + if hasErr != nil { + return hasErr + } + if !exists { + return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID) + } + return models.ErrExecutionRotated + } + return nil +} + func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { var exists int err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` diff --git a/apps/backend/internal/task/repository/sqlite/executor_branch_snapshot_test.go b/apps/backend/internal/task/repository/sqlite/executor_branch_snapshot_test.go new file mode 100644 index 0000000000..218a86b433 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/executor_branch_snapshot_test.go @@ -0,0 +1,40 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestUpdateExecutorRunningWorktreeBranchRejectsRotatedExecution(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedExecutorRunningCleanupTask(t, repo, "task-branch-cas") + seedExecutorRunningCleanupSession(t, repo, "task-branch-cas", "session-branch-cas", models.TaskSessionStateRunning, "exec-successor") + + err := repo.UpdateExecutorRunningWorktreeBranch(ctx, "session-branch-cas", "exec-rotated", "feature/stale") + if !errors.Is(err, models.ErrExecutionRotated) { + t.Fatalf("rotated branch snapshot error = %v, want ErrExecutionRotated", err) + } + + running, err := repo.GetExecutorRunningBySessionID(ctx, "session-branch-cas") + if err != nil { + t.Fatalf("GetExecutorRunningBySessionID: %v", err) + } + if running.WorktreeBranch != "" { + t.Fatalf("rotated write changed branch to %q", running.WorktreeBranch) + } + + if err := repo.UpdateExecutorRunningWorktreeBranch(ctx, "session-branch-cas", "exec-successor", "feature/final-title"); err != nil { + t.Fatalf("current execution branch snapshot: %v", err) + } + running, err = repo.GetExecutorRunningBySessionID(ctx, "session-branch-cas") + if err != nil { + t.Fatalf("GetExecutorRunningBySessionID after current write: %v", err) + } + if running.WorktreeBranch != "feature/final-title" { + t.Fatalf("current execution branch = %q, want feature/final-title", running.WorktreeBranch) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 6c353cb08f..0835a7c4d3 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1938,6 +1938,22 @@ func (r *Repository) UpdateTaskSessionWorktreeBranchByRepository(ctx context.Con return err } +// UpdateTaskSessionWorktreeBranchByWorktree updates exactly one worktree row. +// This is the repository-scoped variant needed when a task attaches multiple +// branches from the same repository. +func (r *Repository) UpdateTaskSessionWorktreeBranchByWorktree(ctx context.Context, sessionID, worktreeID, branch string) error { + now := time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_session_worktrees + SET worktree_branch = ?, updated_at = ? + WHERE session_id = ? + AND worktree_id = ? + AND deleted_at IS NULL + AND status = 'active' + `), branch, now, sessionID, worktreeID) + return err +} + func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index b50b0933a9..b073320fa5 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -815,6 +815,23 @@ func TestUpdateTaskSessionWorktreeBranchByRepositoryScopesUpdate(t *testing.T) { } } +func TestUpdateTaskSessionWorktreeBranchByWorktreeScopesRepeatedRepository(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-repeated-repo", "session-repeated-repo", "turn-repeated-repo") + for _, wt := range []*models.TaskSessionWorktree{ + {ID: "wt-repeated-one", SessionID: "session-repeated-repo", WorktreeID: "worktree-repeated-one", RepositoryID: "repo-repeated", WorktreeBranch: "feature/one", Position: 0}, + {ID: "wt-repeated-two", SessionID: "session-repeated-repo", WorktreeID: "worktree-repeated-two", RepositoryID: "repo-repeated", WorktreeBranch: "feature/two", Position: 1}, + } { + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, wt)) + } + require.NoError(t, repo.UpdateTaskSessionWorktreeBranchByWorktree(ctx, "session-repeated-repo", "worktree-repeated-two", "feature/two-renamed")) + worktrees, err := repo.ListTaskSessionWorktrees(ctx, "session-repeated-repo") + require.NoError(t, err) + require.Equal(t, "feature/one", worktrees[0].WorktreeBranch) + require.Equal(t, "feature/two-renamed", worktrees[1].WorktreeBranch) +} + // TestGetLastAgentMessage_NoMessages verifies that a session with no messages // returns an empty string and sql.ErrNoRows. func TestGetLastAgentMessage_NoMessages(t *testing.T) { diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index c9fd58aab8..3a391e08b8 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -765,7 +765,8 @@ function useTextareaHandlers( ) { const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention; const handleChange = useCallback( - (e: React.ChangeEvent) => mentionHandleChange(e.target.value), + (e: React.ChangeEvent) => + mentionHandleChange(e.target.value, e.target.selectionStart), [mentionHandleChange], ); const handleKeyDown = useCallback( diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index d531c1c6a3..4d5733aea6 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -151,6 +151,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await textarea.click(); await textarea.pressSequentially("@qa-mu"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await expect(testPage.getByRole("option", { name: /qa-multi/ })).toBeVisible(); await textarea.press("Enter"); await expect(textarea).toHaveValue(lines); diff --git a/apps/web/hooks/use-inline-mention.test.ts b/apps/web/hooks/use-inline-mention.test.ts index 13014715b0..e2b831abcb 100644 --- a/apps/web/hooks/use-inline-mention.test.ts +++ b/apps/web/hooks/use-inline-mention.test.ts @@ -1,12 +1,31 @@ -import { describe, it, expect, vi } from "vitest"; +import { cleanup, renderHook, act } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi } from "vitest"; + +vi.mock("@/hooks/domains/settings/use-custom-prompts", () => ({ + useCustomPrompts: () => ({ + prompts: [ + { id: "builtin", name: "changes-walkthrough", content: "walkthrough" }, + { id: "qa-multi", name: "qa-multi", content: "multi-line content" }, + ], + loaded: true, + loading: false, + }), +})); + import { makePromptItem, detectMentionTrigger, filterItems, + useInlineMention, type MentionItem, } from "./use-inline-mention"; import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + function makeFakeInput(value: string, caretPos: number): RichTextInputHandle { let selStart = caretPos; let selEnd = caretPos; @@ -27,6 +46,73 @@ function makeFakeInput(value: string, caretPos: number): RichTextInputHandle { }; } +function makeMutableInput(state: { value: string; caretPos: number }): RichTextInputHandle { + return { + focus: vi.fn(), + blur: vi.fn(), + getSelectionStart: () => state.caretPos, + getSelectionEnd: () => state.caretPos, + setSelectionRange: (start: number) => { + state.caretPos = start; + }, + getCaretRect: () => ({ x: 0, y: 0 }) as DOMRect, + getValue: () => state.value, + setValue: vi.fn(), + insertText: vi.fn(), + getTextareaElement: () => null, + }; +} + +describe("useInlineMention scheduling", () => { + it("ignores a stale animation frame after a newer input change", () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + const inputState = { value: "", caretPos: 0 }; + const inputRef = { current: makeMutableInput(inputState) }; + const { result, rerender } = renderHook( + ({ value }: { value: string }) => + useInlineMention({ + inputRef, + value, + onChange: (nextValue) => { + inputState.value = nextValue; + inputState.caretPos = nextValue.length; + }, + promptInsertMode: "inline", + }), + { initialProps: { value: "" } }, + ); + + act(() => { + inputState.value = "@"; + inputState.caretPos = 1; + result.current.handleChange("@", 1); + rerender({ value: "@" }); + }); + act(() => { + inputState.value = "@qa-mu"; + inputState.caretPos = 6; + result.current.handleChange("@qa-mu", 6); + rerender({ value: "@qa-mu" }); + }); + + expect(frames).toHaveLength(2); + inputState.caretPos = 1; + act(() => { + frames[1](0); + frames[0](0); + }); + + expect(result.current.query).toBe("qa-mu"); + expect(result.current.items.map((item) => item.label)).toEqual(["qa-multi"]); + }); +}); + describe("makePromptItem — context mode (default chat behavior)", () => { it("deletes the @query text and calls onPromptSelect", () => { const prompt = { id: "p1", name: "bug-template", content: "Reproduce, isolate, fix." }; diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index c7de00659e..c2c6f5406f 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -36,6 +36,39 @@ type Position = { y: number; }; +function useMentionChangeScheduler() { + const versionRef = useRef(0); + const frameRef = useRef(null); + + const cancelPending = useCallback(() => { + if (frameRef.current === null) return; + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }, []); + + const schedule = useCallback( + (callback: () => void) => { + const version = ++versionRef.current; + cancelPending(); + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + if (version !== versionRef.current) return; + callback(); + }); + }, + [cancelPending], + ); + + const invalidate = useCallback(() => { + versionRef.current += 1; + cancelPending(); + }, [cancelPending]); + + useEffect(() => invalidate, [invalidate]); + + return { schedule, invalidate }; +} + // Debounce delay for file search (ms) const FILE_SEARCH_DEBOUNCE = 300; @@ -269,7 +302,8 @@ function useMentionKeyboard({ case "Tab": if (filteredItems.length > 0) { event.preventDefault(); - handleSelect(filteredItems[selectedIndex]); + const index = Math.min(selectedIndex, filteredItems.length - 1); + handleSelect(filteredItems[index]); } break; case "Escape": @@ -339,6 +373,8 @@ export function useInlineMention({ const [triggerStart, setTriggerStart] = useState(-1); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); + const { schedule: scheduleMentionChange, invalidate: invalidateMentionChange } = + useMentionChangeScheduler(); const { prompts } = useCustomPrompts(); const { fileResults, isLoading } = useFileSearch(sessionId, isOpen, query); @@ -355,7 +391,7 @@ export function useInlineMention({ /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { setSelectedIndex(0); - }, [filteredItems.length]); + }, [query, filteredItems.length]); useEffect(() => { if (!isOpen || isLoading) return; @@ -366,34 +402,35 @@ export function useInlineMention({ /* eslint-enable react-hooks/set-state-in-effect */ const handleChange = useCallback( - (newValue: string) => { + (newValue: string, cursorPosition?: number) => { onChange(newValue); const input = inputRef.current; if (!input) return; - requestAnimationFrame(() => { - const cursorPos = input.getSelectionStart(); - const trigger = detectMentionTrigger(newValue, cursorPos); + const mentionCursorPosition = cursorPosition ?? input.getSelectionStart(); + + scheduleMentionChange(() => { + const trigger = detectMentionTrigger(newValue, mentionCursorPosition); if (trigger) { const caretRect = input.getCaretRect(); if (caretRect) { setPosition({ x: caretRect.x, y: caretRect.y }); setTriggerStart(trigger.triggerStart); setQuery(trigger.query); + setSelectedIndex(0); setIsOpen(true); return; } } - if (isOpen) { - clearMentionState(setIsOpen, setTriggerStart, setQuery); - } + clearMentionState(setIsOpen, setTriggerStart, setQuery); }); }, - [inputRef, isOpen, onChange], + [inputRef, onChange, scheduleMentionChange], ); const closeMenu = useCallback(() => { + invalidateMentionChange(); clearMentionState(setIsOpen, setTriggerStart, setQuery); - }, []); + }, [invalidateMentionChange]); const handleSelect = useCallback( (item: MentionItem) => { diff --git a/docs/plans/agent-title-branch-renaming/plan.md b/docs/plans/agent-title-branch-renaming/plan.md new file mode 100644 index 0000000000..5ab3f6a62b --- /dev/null +++ b/docs/plans/agent-title-branch-renaming/plan.md @@ -0,0 +1,150 @@ +--- +spec: docs/specs/tasks/agent-generated-titles.md +created: 2026-08-04 +status: complete +--- + +# Implementation Plan: Agent Title Branch Renaming + +## Overview + +Extend the existing owner-only `set_task_title_kandev` transaction with a per-repository +rename of branches Kandev generated from the provisional title. The runtime layer first gains a +repository-scoped rename operation and durable snapshot update; the MCP handler then invokes it only +after the title compare-and-set succeeds and returns explicit outcomes. Direct remote checkouts, +Local-executor branches, manual titles, and non-owner sessions remain unchanged. + +Relevant decisions: +[agent-generated task titles](../../decisions/2026-07-31-agent-generated-task-titles.md), +[single-owner title handoff](../../decisions/2026-08-02-single-owner-agent-task-titles.md), and +[configurable worktree branch names](../../decisions/0032-configurable-worktree-branch-names.md). + +--- + +## Backend + +### Owner-session branch rename runtime + +- Add an orchestrator contract and result types in + `apps/backend/internal/orchestrator/task_title_branch.go` for + `RenameGeneratedBranchesForTaskTitle(ctx, taskID, sessionID, title)`. Authorize the task/session pair + before loading workspace state. The MCP handler may invoke it only after the task service has accepted + that same session as the persisted title owner; the owner metadata is cleared by that acceptance. +- Load the owner session's `TaskSessionWorktree` rows, task-repository bindings, repository branch + settings, task environment, and executor profile. Use the existing branch identity plans to match + worktrees to task repositories in single- and multi-repository tasks. +- Treat a non-empty `TaskRepository.CheckoutBranch` as an explicit checkout and emit a + `remote_checkout` preservation result without Git mutation. Emit `local_executor` for all Local + executor branches. These checks happen before rendering or invoking agentctl. +- For each remaining worktree, call `worktree.RenderTaskBranchName` with the accepted title, task ID, + ticket/issue metadata, repository template, and a deterministic executor-specific suffix. Do not + special case templates without `{suffix}`; collision behavior remains the Git layer's responsibility. +- Add a narrow lifecycle operation in + `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go` that resolves or restores + the session execution, selects the repository subpath, and delegates to the existing agentctl + `GitRenameBranch` call. Keep the operation repository-scoped for multi-repository workspaces. +- After each successful Git rename, update the matching `task_session_worktrees` row and the + corresponding `TaskEnvironmentRepo.WorktreeBranch`. Update the legacy primary + `TaskEnvironment.WorktreeBranch`, running-executor snapshot, and live execution metadata only when + they describe that same primary repository. Add repository methods rather than relying on the + Changes-panel callback, so completion is synchronous before the MCP response and survives restart. +- Return per-repository renamed, preserved, and failed outcomes. Continue after an individual Git + or snapshot failure; never rename or delete a remote branch and never roll back a previously + successful rename. + +### Title tool integration + +- Add a small `TaskTitleBranchRenamer` dependency to + `apps/backend/internal/mcp/handlers/handlers.go`, wired to the orchestrator in + `apps/backend/internal/backendapp/helpers.go`. Keep task-title ownership and compare-and-set logic in + `task.Service.SetPendingAgentTitle` unchanged. +- In `handleSetTaskTitle`, call the branch renamer only when `SetPendingAgentTitle` returns + `accepted: true`. The persisted task title and cleared pending state are authoritative before Git is + attempted; a Git error must not undo them. +- Extend the success payload with `branch_rename.status` and repository-scoped `renamed`, `preserved`, + and `failed` arrays. Preserve the existing `accepted: false` responses exactly, and return accepted + title data with `partial` or `failed` branch status instead of turning a Git failure into an MCP + transport error. +- Update the tool description in `apps/backend/internal/mcp/server/server.go` to state that accepted + auto-titles also rename eligible Kandev-generated branches while preserving explicit remote + checkouts. Do not expose the tool in any additional MCP mode. + +### Public documentation + +- Update `docs/public/automation-and-mcp.md` to explain that the default-on prompt-first option also + renames the owner session's Kandev-generated branch after the agent chooses the final title, while an + opt-out or manual title leaves branch behavior unchanged. +- Update `docs/public/git-operations.md` with the remote-change preservation rule, multi-repository + behavior, and non-transactional failure outcome. Keep the existing manual **Edit branch** workflow + documented separately. + +--- + +## Tests + +- **What:** final-title rendering uses default/custom templates and current task metadata, while an + explicit checkout and Local executor are preserved before Git is called. **Files:** + `apps/backend/internal/orchestrator/task_title_branch_test.go`, + `apps/backend/internal/orchestrator/task_title_branch_service_test.go`, and existing worktree config + tests. + **How:** table-driven tests with fake lifecycle/repository dependencies, covering single repository, + mixed multi-repository, custom template, direct GitHub PR checkout, and Local executor cases. +- **What:** the lifecycle operation resolves the correct execution and repository subpath and relays + agentctl success/failure without touching another repository. **File:** + `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go`. **How:** focused + unit tests with fake execution and agentctl clients. +- **What:** successful Git renames synchronously update repository-scoped session worktrees, + task-environment rows, the primary legacy snapshot, and applicable running/live metadata; failed + renames leave old snapshots intact. **Files:** orchestrator and SQLite repository tests. **How:** real + SQLite integration tests plus lifecycle fakes, followed by a reload/resume assertion. +- **What:** accepted title calls trigger branch renaming once and return `renamed`, `preserved`, + `partial`, `failed`, or `not_applicable`; rejected/non-owner/repeated calls never invoke Git. + **Files:** `apps/backend/internal/mcp/handlers/set_task_title_test.go` and MCP server catalog tests. + **How:** handler-to-task-service integration with a real task repository and fake branch renamer. +- **What:** a branch failure after title persistence does not restore pending metadata or revert the + accepted title. **File:** `apps/backend/internal/mcp/handlers/set_task_title_test.go`. **How:** fail + the fake branch rename and reload the task after the handler response. +- **What:** only the persisted owner session is targeted; concurrent non-owner worktrees remain + unchanged. **File:** `apps/backend/internal/orchestrator/task_title_branch_service_test.go`. **How:** seed two + sessions and assert Git and snapshot mutations are scoped to the owner. + +No new frontend or browser E2E test is planned. The existing option and creation payload are unchanged; +the new behavior begins after the task-bound MCP call and is covered at the backend integration layer. + +## Verification Results + +- Task 01 exact targeted backend check: 31 passed in 4 packages. +- Task 02 exact MCP/backendapp check: 12 passed in 3 packages. +- Affected-package backend check: 3469 passed in 6 packages. +- Focused race check: 11 passed in 3 packages. +- Backend lint: `make -C apps/backend lint` — 0 issues. +- Public-doc checks: 58 Node tests passed; 41 published docs pages validated; `git diff --check` + passed. +- No generated artifacts or persistent external branches were created; HTTP agentctl calls are + covered by local test servers and production renames remain scoped to the owner session's workspace. + +### PR Fixup Remediation + +- Addressed review findings for manual-branch preservation, synchronized lifecycle metadata, execution-ID + compare-and-swap snapshots, repository-scoped branch-switch updates, nil-runtime handling, deterministic + worktree suffixes, and surfaced snapshot failures. +- Follow-up review fixes add owner/non-owner session isolation and actual snapshot-write failure coverage, + document the `switched_branch` preservation reason, make repeated-repository fixtures deterministic, + and correct the recorded test paths and verification command scope. +- Remediation verification: targeted backend check — 32 passed in 6 packages; affected-package backend + check — 3473 passed in 6 packages; focused race check — 12 passed in 3 packages; architecture lint, + backend lint (0 issues), public-doc checks (58 tests and 41 pages), and `git diff --check` passed. + +## Implementation Waves And Parallel Candidates + +The default execution order is sequential in the primary conversation. These waves do not authorize +subagents. + +Wave 1: +- [x] [Task 01: Branch rename runtime](task-01-branch-rename-runtime.md) + +Wave 2: +- [x] [Task 02: Title tool integration](task-02-title-tool-integration.md) + +Wave 3: +- [x] [Task 03: Regression documentation](task-03-regression-documentation.md) diff --git a/docs/plans/agent-title-branch-renaming/task-01-branch-rename-runtime.md b/docs/plans/agent-title-branch-renaming/task-01-branch-rename-runtime.md new file mode 100644 index 0000000000..b2189b16f8 --- /dev/null +++ b/docs/plans/agent-title-branch-renaming/task-01-branch-rename-runtime.md @@ -0,0 +1,82 @@ +--- +id: "01-branch-rename-runtime" +title: "Branch rename runtime" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/agent-generated-titles.md" +--- + +# Task 01: Branch rename runtime + +## Acceptance + +- The orchestrator identifies only the title-owner session's Kandev-generated repository branches; + explicit `checkout_branch` bindings and all Local-executor branches produce preservation outcomes + without invoking Git. +- Eligible names reuse the repository's configured branch template and the final task-title context, + and Git renames are scoped to the correct repository subpath in multi-repository workspaces. +- Each successful rename synchronously updates the repository-scoped session worktree and applicable + environment/running/live snapshots. Individual failures leave their old snapshots intact and do not + stop other eligible repositories. + +## Verification + +```bash +cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/task/repository/sqlite ./internal/worktree -run 'Test.*(TaskTitleBranch|Rename.*Branch|BranchName)' +``` + +## Files likely touched + +- `apps/backend/internal/orchestrator/task_title_branch.go` +- `apps/backend/internal/orchestrator/task_title_branch_test.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_branch_test.go` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/task/repository/sqlite/task_environment.go` +- focused SQLite repository tests +- `apps/backend/internal/worktree/config.go` and tests, only if a shared final-title renderer helper is + needed + +## Dependencies + +None. Reuse the shipped title-owner metadata, branch-template renderer, agentctl Git rename, and +repository-scoped worktree models. + +## Parallelism + +Sequential in the primary conversation. The runtime contract and persistence behavior are inputs to +the MCP integration. + +## Inputs + +- Spec sections: **What**, **Data model**, **Failure modes**, **Persistence guarantees**, and branch + scenarios. +- ADR 0032 branch-template contract and the existing Changes-panel branch rename implementation. +- `TaskRepository.CheckoutBranch`, `TaskSessionWorktree.RepositoryID`, and task-environment repository + rows. + +## Risks + +- Direct remote checkouts may use a suffixed local branch when the head branch is already checked out; + eligibility must use persisted `checkout_branch`, not compare current branch strings. +- Partial multi-repository success cannot be transactional with Git. Persist each successful rename + before continuing and report failures without guessing rollback behavior. +- Legacy primary snapshots must not be overwritten for a non-primary repository. + +## Results + +- Implemented the lifecycle `GitRenameBranch` seam, primary running-row/metadata update, and + repository/worktree-scoped SQLite snapshot writers. +- Implemented orchestrator branch ownership matching, template rendering, deterministic executor-specific + suffixes, manual-branch/remote-checkout/Local preservation, mixed multi-repository outcomes, and + surfaced snapshot failures. Branch-switch events now scope snapshot updates to the tagged + repository/worktree. +- Exact verification: `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/task/repository/sqlite ./internal/worktree -run 'Test.*(TaskTitleBranch|Rename.*Branch|BranchName)'` — 31 passed in 4 packages. +- Affected-package verification: `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/mcp/handlers ./internal/mcp/server ./internal/backendapp ./internal/task/repository/sqlite` — 3469 passed in 6 packages. +- Focused race verification: `cd apps/backend && go test -race ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/mcp/handlers -run 'Test.*(TaskTitleBranch|RenameBranchForSession|SetTaskTitle|HandleBranchSwitched)'` — 11 passed in 3 packages. +- `make -C apps/backend lint` — golangci-lint reported 0 issues. +- Git operations remain local to the owning execution; no remote branch or task directory is renamed. diff --git a/docs/plans/agent-title-branch-renaming/task-02-title-tool-integration.md b/docs/plans/agent-title-branch-renaming/task-02-title-tool-integration.md new file mode 100644 index 0000000000..954ea96bf2 --- /dev/null +++ b/docs/plans/agent-title-branch-renaming/task-02-title-tool-integration.md @@ -0,0 +1,67 @@ +--- +id: "02-title-tool-integration" +title: "Title tool integration" +status: done +wave: 2 +depends_on: ["01-branch-rename-runtime"] +plan: "plan.md" +spec: "../../specs/tasks/agent-generated-titles.md" +--- + +# Task 02: Title tool integration + +## Acceptance + +- `set_task_title_kandev` invokes the branch runtime only after the title-owner compare-and-set is + accepted; disabled/manual-title tasks and rejected, non-owner, or repeated calls never invoke it. +- A successful response keeps the existing accepted task/title contract and adds deterministic + repository-scoped branch outcomes, including preserved and partial/failed states. +- Git failure cannot roll back the accepted title or restore pending ownership metadata, and the MCP + tool remains exposed only to the existing eligible owner session. + +## Verification + +```bash +cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./internal/backendapp -run 'Test.*(SetTaskTitle|TaskTitle)' +``` + +## Files likely touched + +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/set_task_title_test.go` +- `apps/backend/internal/mcp/server/server.go` +- focused MCP server tests +- `apps/backend/internal/backendapp/helpers.go` +- focused backendapp wiring tests + +## Dependencies + +Task 01's orchestrator contract and outcome types. + +## Parallelism + +Sequential in the primary conversation because the handler response is defined by Task 01's runtime +results. + +## Inputs + +- Spec sections: **Task MCP**, **Permissions**, and **Failure modes**. +- The shipped `task.Service.SetPendingAgentTitle` authorization and compare-and-set behavior. +- Task 01's repository-scoped renamed/preserved/failed outcomes. + +## Risks + +- Returning an MCP transport error for a Git failure would incorrectly imply that the title was not + accepted. Keep title acceptance separate from branch outcome status. +- Do not broaden tool registration or allow the agent to provide task/session IDs. + +## Results + +- Wired `TaskTitleBranchRenamer` into `set_task_title_kandev` only after the title compare-and-set + accepts the owner session. Rejected and repeated calls retain their existing response shape and do + not invoke the branch runtime. +- Accepted responses now include repository-scoped `branch_rename` outcomes; Git/runtime failures are + reported as failed branch outcomes without undoing the accepted title or pending-marker cleanup. +- Updated the MCP tool description and production dependency wiring without broadening tool + registration or allowing caller-supplied task/session IDs. +- Exact verification: `cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./internal/backendapp -run 'Test.*(SetTaskTitle|TaskTitle)'` — 12 passed in 3 packages. diff --git a/docs/plans/agent-title-branch-renaming/task-03-regression-documentation.md b/docs/plans/agent-title-branch-renaming/task-03-regression-documentation.md new file mode 100644 index 0000000000..c2579a25de --- /dev/null +++ b/docs/plans/agent-title-branch-renaming/task-03-regression-documentation.md @@ -0,0 +1,76 @@ +--- +id: "03-regression-documentation" +title: "Regression documentation" +status: done +wave: 3 +depends_on: ["02-title-tool-integration"] +plan: "plan.md" +spec: "../../specs/tasks/agent-generated-titles.md" +--- + +# Task 03: Regression documentation + +## Acceptance + +- Focused integration coverage proves default/custom generated-branch renaming, remote and Local + preservation, mixed multi-repository outcomes, owner scoping, restart-safe snapshots, and failure + semantics through the title-tool boundary. +- Public task and Git documentation explains the option gate, final-title rename, explicit remote + checkout preservation, and surfaced Git/snapshot failure behavior without implying that remote + branches move. +- The affected backend packages and public-document validators pass, with results recorded in this + task and the parent plan. + +## Verification + +```bash +(cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/mcp/handlers ./internal/mcp/server ./internal/task/repository/sqlite ./internal/worktree -run 'Test.*(TaskTitleBranch|SetTaskTitle|Rename.*Branch|BranchName)') +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Files likely touched + +- focused tests introduced or extended by Tasks 01–02 +- `docs/public/automation-and-mcp.md` +- `docs/public/git-operations.md` +- `docs/public/coverage.json`, only if the existing ownership map requires an update +- `docs/plans/agent-title-branch-renaming/plan.md` +- `docs/plans/agent-title-branch-renaming/task-01-branch-rename-runtime.md` +- `docs/plans/agent-title-branch-renaming/task-02-title-tool-integration.md` +- `docs/plans/agent-title-branch-renaming/task-03-regression-documentation.md` + +## Dependencies + +Tasks 01–02 complete. + +## Parallelism + +Sequential in the primary conversation. The integration assertions and documentation must describe +the landed contract. + +## Inputs + +- All new branch scenarios in the spec. +- Public task-title guidance in `docs/public/automation-and-mcp.md` and managed-branch guidance in + `docs/public/git-operations.md`. +- Exact results from Tasks 01–02. + +## Risks + +- Documentation must distinguish local branch rename from a remote branch mutation. +- Avoid browser E2E churn: the setting and dialog payload do not change, so backend integration is the + correct boundary for this extension. + +## Results + +- Added regression coverage for final-title rendering, custom templates, remote checkout and Local + preservation, mixed multi-repository outcomes, owner-only invocation, failed Git side effects, live + snapshots, repeated same-repository worktrees, and repository-scoped branch-switch events. +- Updated `docs/public/automation-and-mcp.md` and `docs/public/git-operations.md` with the option gate, + final-title rename behavior, remote/PR preservation, Local preservation, and surfaced failure + semantics. +- `node --test scripts/validate-public-docs.test.mjs` — 58 passed. +- `node scripts/validate-public-docs.mjs` — validated 41 published docs pages. +- `git diff --check` — passed. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index df234e13b5..1fcb2128ad 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -181,6 +181,13 @@ Office sessions, and every later session on the task—even if the owner fails b rename wins if it happens first; a late owner call returns `title_not_pending`, while a non-owner call returns `title_not_owner`, without changing the title. +When the owner accepts a generated title, Kandev also updates the names of the task's Kandev-managed +branches from that final title and refreshes the session's branch snapshots. This is evaluated per +repository: a repository opened from an existing checkout branch (including a GitHub PR) is preserved, +as is every Local/Local PC checkout. A branch manually selected before the title call is preserved. +If one managed repository cannot be renamed or its snapshot cannot be persisted, the title remains +accepted and the response reports the successful, preserved, and failed branch outcomes separately. + Task identity is injected for operations that require it. Workspace, parent/subtask, executor, and task-state rules still apply. ### Provider-scoped review automation tools diff --git a/docs/public/git-operations.md b/docs/public/git-operations.md index 243734f297..2f9af538e6 100644 --- a/docs/public/git-operations.md +++ b/docs/public/git-operations.md @@ -49,6 +49,14 @@ feature/{title}-{suffix} When a task opens an existing branch or GitHub PR, Kandev fetches that branch; for a numbered GitHub PR it can fetch `refs/pull/NUMBER/head`, including fork PRs. If the intended branch is already checked out in another worktree, the new worktree uses a suffixed local branch and tracks the original `origin` branch when available. If remote fetch fails but the local branch exists, Kandev can continue with that possibly stale branch and reports the fallback. +Tasks created without an initial title can expose the one-shot `set_task_title_kandev` handoff when +**Settings → General → Task Actions → Agent-generated task titles** is enabled. After the owning +session accepts its final title, Kandev regenerates Kandev-managed branch names from that title and +updates the stored branch snapshots. It never renames a repository row with an explicit checkout +branch (for example, a GitHub PR branch) or a Local/Local PC checkout. A branch manually selected +before the title call is preserved as well. Multi-repository tasks apply these rules independently to +each repository, and a Git or snapshot persistence failure does not undo the accepted title. + After creation, Kandev copies any repository-configured files and runs its setup script. Setup-script failure is non-fatal: the worktree remains and the session surfaces a warning. Cleanup scripts run before worktree removal, but their failure also does not prevent removal. ## Everyday operations diff --git a/docs/specs/tasks/agent-generated-titles.md b/docs/specs/tasks/agent-generated-titles.md index dae2b7ec95..db9d662997 100644 --- a/docs/specs/tasks/agent-generated-titles.md +++ b/docs/specs/tasks/agent-generated-titles.md @@ -1,7 +1,7 @@ --- status: approved created: 2026-07-31 -updated: 2026-08-02 +updated: 2026-08-04 owner: kandev --- @@ -47,8 +47,24 @@ and request with a target of about six words in sentence case and uses a short title phrase rather than a sentence or progress update. - A successful agent title call updates every live task surface through the normal `task.updated` - event and ends the pending state. Every other session omits the instruction and tool, including when - it launches concurrently with the owner or after the owner's launch or title call fails. + event, ends the pending state, and renames each Kandev-generated local branch in the owner session + from the provisional-title name to a name rendered from the accepted title. Rendering reuses that + repository's configured worktree branch template and the task's existing branch-name context. +- Branch renaming remains part of the opt-in lifecycle: tasks created without `auto_title: true`, + including tasks created while **Let agents name new tasks** is disabled, are never renamed by a title + call. Ordinary human title edits also do not rename branches. +- A task repository with an explicit `checkout_branch` is preserved. This includes tasks created from + a GitHub pull request or another remote change link, so the session remains on the remote head branch + and can contribute changes to it. Local-executor sessions are also preserved because Kandev does not + own their shared checkout branch. +- If the owner session switched away from its recorded generated branch before the title call arrives, + Kandev preserves the current branch rather than treating that user selection as Kandev-owned. +- In multi-repository tasks, eligibility is evaluated per repository. Kandev can rename generated + branches and preserve explicit remote checkouts in the same owner session. Sessions other than the + title owner keep their current branches; future sessions render new branches from the final task + title through the normal creation path. +- Every other session omits the instruction and tool, including when it launches concurrently with the + owner or after the owner's launch or title call fails. - A user or other ordinary task-title update made while the agent title is pending wins: it ends the pending state, and a later `set_task_title_kandev` call does not overwrite it. - Task and subtask dialogs provide the same capability on desktop and phone. Their existing responsive @@ -70,6 +86,11 @@ by their first eligible launch. No database column or schema migration is required. +Existing `task_repositories.checkout_branch` values are the durable signal that a repository was +opened on an explicit branch and must be preserved. Successful generated-branch renames update the +repository-scoped `task_session_worktrees` row and the corresponding task-environment and running +executor branch snapshots used by restart and resume. The task workspace directory is not renamed. + ## API surface ### User settings @@ -101,8 +122,15 @@ The owner session for a title-pending task exposes: ``` The tool targets the current task bound to the MCP server. A successful response contains -`{"accepted": true, "task_id": "...", "title": "..."}`. A task whose pending marker is already gone -returns `{"accepted": false, "reason": "title_not_pending", "task_id": "..."}` without mutation. +`{"accepted": true, "task_id": "...", "title": "...", "branch_rename": {...}}`. The +`branch_rename` object reports an aggregate `status` of `renamed`, `preserved`, `partial`, `failed`, or +`not_applicable`, plus repository-scoped `renamed`, `preserved`, and `failed` entries. Preserved entries +identify `remote_checkout`, `local_executor`, or `switched_branch` as their reason. `renamed` means at least one rename +succeeded and none failed, even when other repositories were intentionally preserved; `partial` means +some eligible renames succeeded and others failed; `failed` means every eligible rename failed; +`preserved` means there were branches but none were eligible; and `not_applicable` means the owner had +no branch rows. A task whose pending marker is already gone returns +`{"accepted": false, "reason": "title_not_pending", "task_id": "..."}` without mutation. An unavailable call from a session other than the persisted owner returns `{"accepted": false, "reason": "title_not_owner", "task_id": "..."}` without mutation. Blank titles and titles over the existing 500-character limit are validation errors. @@ -151,6 +179,14 @@ retain the now-idempotent tool until its server ends. returns `title_not_pending`. - If the title update cannot be persisted, the tool returns an error, the pending marker remains, and no success event is published. +- The title compare-and-set completes before any Git side effect. If one or more branch renames fail, + the accepted title and cleared pending state are not rolled back. Successful repositories keep their + new names and durable snapshots; failed repositories keep their old names. The tool reports the + partial or failed outcome, and a repeated title call does not retry because the title is no longer + pending. +- A configured branch template can still produce a collision, including when it omits `{suffix}`. The + underlying Git rename fails normally and is reported without deleting, force-renaming, or changing a + remote branch. ## Persistence guarantees @@ -159,6 +195,9 @@ applies across the current user's workspaces. The provisional title and pending session, and executor restarts as part of the task row. Once claimed, the owner session ID survives in the same metadata. MCP catalog state is reconstructed from task mode, pending state, and owner identity; the same owner can recover its capability, while a different session cannot inherit it after restart. +After a successful generated-branch rename, repository-scoped workspace and executor snapshots use the +new branch name so backend restart, executor recovery, and session resume do not restore the +provisional-title name. ## Scenarios @@ -192,6 +231,28 @@ the same owner can recover its capability, while a different session cannot inhe - **GIVEN** the agent follows the tool guidance and calls `set_task_title_kandev` with a valid few-word title, **WHEN** persistence succeeds, **THEN** the task title changes, pending state ends, and connected task surfaces update. +- **GIVEN** an auto-titled task owner is on a Kandev-generated branch, **WHEN** its title call is + accepted, **THEN** the branch is renamed from the provisional-title rendering to a rendering of the + accepted title using that repository's configured template, and restart/resume snapshots record the + new name. +- **GIVEN** a task was created without `auto_title: true`, **WHEN** its title is edited, **THEN** no + title-driven branch rename occurs. +- **GIVEN** an auto-titled task started from a GitHub pull request or other direct remote checkout, + **WHEN** the owner title call is accepted, **THEN** the task title changes but the checked-out remote + branch name is preserved. +- **GIVEN** an auto-titled Local-executor task, **WHEN** the owner title call is accepted, **THEN** the + shared checkout branch is preserved. +- **GIVEN** an auto-titled task whose owner switched to another branch before the title call, **WHEN** + the owner title call is accepted, **THEN** the selected branch is preserved and reported as preserved. +- **GIVEN** an auto-titled multi-repository task mixes a generated branch with a direct remote + checkout, **WHEN** the owner title call is accepted, **THEN** the generated branch is renamed and the + direct remote checkout is preserved with both outcomes reported. +- **GIVEN** a generated-branch rename fails after title persistence, **WHEN** the tool returns, **THEN** + the accepted task title remains, the failed repository stays on its old branch, and the response + reports the failure without mutating any remote branch. +- **GIVEN** a second task session already exists when the owner sets the title, **WHEN** branch renaming + runs, **THEN** only the owner session's eligible branches are renamed and the other session keeps its + current branches. - **GIVEN** a user manually renames a title-pending task, **WHEN** the agent later calls `set_task_title_kandev`, **THEN** the user title is preserved and the tool returns `title_not_pending`. @@ -209,7 +270,11 @@ the same owner can recover its capability, while a different session cannot inhe - Reassigning title ownership or retrying the title instruction with a different session after the owner launch or title call fails. - Removing manual task rename/edit controls. +- Renaming remote branches, deleting the provisional branch remotely, renaming the task workspace + directory, or renaming branches in non-owner sessions. +- Automatically retrying a failed Git rename after the title-pending lifecycle has resolved. ## Implementation plan - [Agent-Generated Task Titles](../../plans/agent-generated-task-titles/plan.md) +- [Agent Title Branch Renaming](../../plans/agent-title-branch-renaming/plan.md)