feat: rename generated task branches (#2262)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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." };
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user