feat: rename generated task branches (#2262)

This commit is contained in:
Carlos Florêncio
2026-08-05 18:23:57 +01:00
committed by GitHub
parent ae31fa85a9
commit ad70426d46
33 changed files with 2456 additions and 31 deletions
@@ -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
@@ -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{
@@ -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
}
@@ -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)
}
}
@@ -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 {
@@ -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
@@ -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)
@@ -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},
@@ -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)
}
@@ -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()
+4 -1
View File
@@ -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.")),
@@ -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
@@ -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
@@ -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
@@ -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
}
@@ -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
}
@@ -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")
}
@@ -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)
}
}
@@ -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(`
@@ -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)
}
}
@@ -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
@@ -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) {
@@ -765,7 +765,8 @@ function useTextareaHandlers(
) {
const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => mentionHandleChange(e.target.value),
(e: React.ChangeEvent<HTMLTextAreaElement>) =>
mentionHandleChange(e.target.value, e.target.selectionStart),
[mentionHandleChange],
);
const handleKeyDown = useCallback(
@@ -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);
+87 -1
View File
@@ -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." };
+48 -11
View File
@@ -36,6 +36,39 @@ type Position = {
y: number;
};
function useMentionChangeScheduler() {
const versionRef = useRef(0);
const frameRef = useRef<number | null>(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<number>(-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) => {
@@ -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)
@@ -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.
@@ -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.
@@ -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.
+7
View File
@@ -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
+8
View File
@@ -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
+70 -5
View File
@@ -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)