diff --git a/apps/backend/internal/github/poller.go b/apps/backend/internal/github/poller.go index d63a7b2e77..d23149c427 100644 --- a/apps/backend/internal/github/poller.go +++ b/apps/backend/internal/github/poller.go @@ -141,14 +141,18 @@ func (p *Poller) checkSinglePRWatch(ctx context.Context, watch *PRWatch) { return // Keep watch so the next cycle can retry } - // Auto-cleanup: remove watch when PR is merged or closed. + // When the tracked PR is merged or closed, reset the watch back to the + // "searching" state (pr_number=0) rather than deleting it. This lets the + // poller discover a follow-up PR opened on the same branch (e.g. the user + // closes #1 and opens #2 as a replacement) without requiring manual + // intervention. The watch is only deleted when its owning session is gone. if status.PR != nil && (status.PR.State == prStateMerged || status.PR.State == prStateClosed) { p.publishPRStatusEvent(ctx, watch, status) - if delErr := p.service.DeletePRWatch(ctx, watch.ID); delErr != nil { - p.logger.Error("failed to delete completed PR watch", - zap.String("id", watch.ID), zap.Error(delErr)) + if resetErr := p.service.store.UpdatePRWatchPRNumber(ctx, watch.ID, 0); resetErr != nil { + p.logger.Error("failed to reset completed PR watch", + zap.String("id", watch.ID), zap.Error(resetErr)) } else { - p.logger.Info("removed PR watch for completed PR", + p.logger.Info("reset PR watch after PR completion", zap.String("id", watch.ID), zap.String("state", status.PR.State), zap.Int("pr_number", watch.PRNumber)) diff --git a/apps/backend/internal/github/poller_test.go b/apps/backend/internal/github/poller_test.go index 88de4ae36e..14b699d966 100644 --- a/apps/backend/internal/github/poller_test.go +++ b/apps/backend/internal/github/poller_test.go @@ -41,7 +41,7 @@ func setupPollerTest(t *testing.T) (*Poller, *Service, *MockClient, *Store) { return poller, svc, mockClient, store } -func TestCheckSinglePRWatch_MergedPR_SyncsThenDeletes(t *testing.T) { +func TestCheckSinglePRWatch_MergedPR_SyncsThenResets(t *testing.T) { poller, _, mockClient, store := setupPollerTest(t) ctx := context.Background() @@ -106,13 +106,17 @@ func TestCheckSinglePRWatch_MergedPR_SyncsThenDeletes(t *testing.T) { t.Error("expected task PR MergedAt to be set") } - // Assert: PRWatch should be deleted. + // Assert: PRWatch should be reset to pr_number=0 (still present so the + // poller can discover a follow-up PR on the same branch). remainingWatch, err := store.GetPRWatchBySession(ctx, "sess-1") if err != nil { t.Fatalf("get PR watch: %v", err) } - if remainingWatch != nil { - t.Error("expected PR watch to be deleted after merged PR") + if remainingWatch == nil { + t.Fatal("expected PR watch to remain after merged PR (reset, not deleted)") + } + if remainingWatch.PRNumber != 0 { + t.Errorf("expected PR watch pr_number=0 after merge, got %d", remainingWatch.PRNumber) } } diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 3543c7ad71..49829d2932 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -208,11 +208,6 @@ func (s *Service) DeletePRWatch(ctx context.Context, id string) error { return s.store.DeletePRWatch(ctx, id) } -// UpdatePRWatchBranch updates a PR watch's branch (e.g. after the agent switches branches). -func (s *Service) UpdatePRWatchBranch(ctx context.Context, id, branch string) error { - return s.store.UpdatePRWatchBranch(ctx, id, branch) -} - // UpdatePRWatchBranchIfSearching atomically updates branch only when pr_number = 0. func (s *Service) UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error { return s.store.UpdatePRWatchBranchIfSearching(ctx, id, branch) @@ -223,6 +218,12 @@ func (s *Service) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber return s.store.UpdatePRWatchPRNumber(ctx, id, prNumber) } +// ResetPRWatch atomically resets a watch's branch and clears its pr_number so +// the poller re-searches for a PR on the new branch. See Store.ResetPRWatch. +func (s *Service) ResetPRWatch(ctx context.Context, id, branch string) error { + return s.store.ResetPRWatch(ctx, id, branch) +} + // CheckPRWatch fetches lightweight PR status for a watch and determines if there are changes. func (s *Service) CheckPRWatch(ctx context.Context, watch *PRWatch) (*PRStatus, bool, error) { if s.client == nil { @@ -307,8 +308,18 @@ func (s *Service) AssociatePRWithTask(ctx context.Context, taskID string, pr *PR MergedAt: pr.MergedAt, ClosedAt: pr.ClosedAt, } - if err := s.store.CreateTaskPR(ctx, tp); err != nil { - return nil, fmt.Errorf("create task PR: %w", err) + // ReplaceTaskPR atomically deletes any existing association for the task + // and inserts the new row inside a single transaction. This preserves the + // effective 1:1 task→PR mapping and prevents a window where the task has + // no associated PR or concurrent calls produce duplicate rows. + if err := s.store.ReplaceTaskPR(ctx, tp); err != nil { + return nil, fmt.Errorf("replace task PR: %w", err) + } + if existing != nil { + s.logger.Info("replaced stale task PR association", + zap.String("task_id", taskID), + zap.Int("old_pr_number", existing.PRNumber), + zap.Int("new_pr_number", pr.Number)) } // Publish event for UI diff --git a/apps/backend/internal/github/service_test.go b/apps/backend/internal/github/service_test.go index ae9f72a416..c3cb081ceb 100644 --- a/apps/backend/internal/github/service_test.go +++ b/apps/backend/internal/github/service_test.go @@ -814,3 +814,55 @@ func TestCleanupMergedReviewTasks_TaskAlreadyDeleted(t *testing.T) { t.Errorf("expected 0 remaining dedup records, got %d", len(remaining)) } } + +// Regression: when a task already has a TaskPR row pointing to an old PR +// (e.g. the first PR was closed and a second one opened on the same or a new +// branch), AssociatePRWithTask must replace the stale row so downstream +// consumers (UI, GetTaskPR) observe the current PR rather than the old one. +func TestAssociatePRWithTask_ReplacesStaleAssociation(t *testing.T) { + svc, store, _ := setupSyncTest(t) + ctx := context.Background() + + // Seed an existing association for PR #1. + if err := store.CreateTaskPR(ctx, &TaskPR{ + TaskID: "t1", + Owner: "owner", + Repo: "repo", + PRNumber: 1, + PRURL: "https://github.com/owner/repo/pull/1", + PRTitle: "First", + HeadBranch: "feat-a", + BaseBranch: "main", + State: "closed", + }); err != nil { + t.Fatalf("seed TaskPR: %v", err) + } + + // Associate a new PR #2 (could be on same or different branch). + newPR := &PR{ + Number: 2, + Title: "Second", + HTMLURL: "https://github.com/owner/repo/pull/2", + HeadBranch: "feat-b", + BaseBranch: "main", + State: "open", + RepoOwner: "owner", + RepoName: "repo", + AuthorLogin: "alice", + } + tp, err := svc.AssociatePRWithTask(ctx, "t1", newPR) + if err != nil { + t.Fatalf("AssociatePRWithTask: %v", err) + } + if tp.PRNumber != 2 { + t.Errorf("returned TaskPR.PRNumber=%d, want 2", tp.PRNumber) + } + + got, err := store.GetTaskPR(ctx, "t1") + if err != nil { + t.Fatalf("GetTaskPR: %v", err) + } + if got == nil || got.PRNumber != 2 { + t.Errorf("GetTaskPR after replace = %+v, want PRNumber=2", got) + } +} diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index 2db6173a33..86ed86e948 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -179,10 +179,14 @@ func (s *Store) UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber i return err } -// UpdatePRWatchBranch updates a PR watch's branch after the agent switches branches. -func (s *Store) UpdatePRWatchBranch(ctx context.Context, id, branch string) error { +// ResetPRWatch atomically resets a watch to the searching state: updates the +// tracked branch and clears pr_number in a single statement. Used when the +// session's active branch changes (rename, checkout) so the poller re-searches +// for a PR on the new branch without leaving an inconsistent intermediate +// state. +func (s *Store) ResetPRWatch(ctx context.Context, id, branch string) error { _, err := s.db.ExecContext(ctx, - `UPDATE github_pr_watches SET branch = ?, updated_at = ? WHERE id = ?`, + `UPDATE github_pr_watches SET branch = ?, pr_number = 0, updated_at = ? WHERE id = ?`, branch, time.Now().UTC(), id) return err } @@ -263,6 +267,40 @@ func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string return result, nil } +// ReplaceTaskPR atomically replaces the task→PR association for a task: any +// existing rows for task_id are deleted and the new row is inserted inside a +// single transaction, matching the effective 1:1 task→PR mapping used by +// reads. Use this when a task's active PR changes (e.g. the first PR was +// closed and a follow-up was opened). +func (s *Store) ReplaceTaskPR(ctx context.Context, tp *TaskPR) error { + if tp.ID == "" { + tp.ID = uuid.New().String() + } + now := time.Now().UTC() + tp.UpdatedAt = now + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM github_task_prs WHERE task_id = ?`, tp.TaskID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO github_task_prs (id, task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch, author_login, + state, review_state, checks_state, review_count, pending_review_count, comment_count, additions, deletions, + created_at, merged_at, closed_at, last_synced_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + tp.ID, tp.TaskID, tp.Owner, tp.Repo, tp.PRNumber, tp.PRURL, tp.PRTitle, tp.HeadBranch, tp.BaseBranch, tp.AuthorLogin, + tp.State, tp.ReviewState, tp.ChecksState, tp.ReviewCount, tp.PendingReviewCount, tp.CommentCount, tp.Additions, tp.Deletions, + tp.CreatedAt, tp.MergedAt, tp.ClosedAt, tp.LastSyncedAt, tp.UpdatedAt); err != nil { + return err + } + return tx.Commit() +} + // UpdateTaskPR updates a task-PR association. func (s *Store) UpdateTaskPR(ctx context.Context, tp *TaskPR) error { tp.UpdatedAt = time.Now().UTC() diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index 008fd8e894..110c6d9082 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -464,6 +464,26 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven } } + // Persist the new branch name to the session's worktree record so downstream + // consumers (PR watch reconciliation, branch listings) observe the current + // branch rather than the value captured at worktree creation. Without this, + // 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 { + 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), + zap.Error(err)) + } + + // Reset the PR watch for this session so the poller re-searches for a PR + // on the new branch. This handles both rename (same PR, new branch name) + // and stacked-PR workflows (switching to a different branch with its own + // open PR). + s.resetPRWatchForBranchSwitch(ctx, data.SessionID, data.BranchSwitch.CurrentBranch) + } + // Forward branch_switched event to WebSocket subject for frontend real-time updates if s.eventBus != nil { event := bus.NewEvent(events.GitEvent, "orchestrator", &lifecycle.GitEventPayload{ @@ -481,3 +501,34 @@ func (s *Service) handleBranchSwitched(ctx context.Context, data watcher.GitEven _ = s.eventBus.Publish(ctx, events.BuildGitWSEventSubject(data.SessionID), event) } } + +// 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 +// is a no-op — a watch will be created on the next push. +func (s *Service) resetPRWatchForBranchSwitch(ctx context.Context, sessionID, newBranch string) { + if s.githubService == nil { + return + } + watch, err := s.githubService.GetPRWatchBySession(ctx, sessionID) + if err != nil { + s.logger.Debug("failed to look up PR watch for branch switch", + zap.String("session_id", sessionID), zap.Error(err)) + return + } + if watch == nil { + return + } + if watch.Branch == newBranch && watch.PRNumber == 0 { + return + } + if err := s.githubService.ResetPRWatch(ctx, watch.ID, newBranch); err != nil { + s.logger.Error("failed to reset PR watch after branch switch", + zap.String("session_id", sessionID), zap.String("new_branch", newBranch), + zap.Error(err)) + return + } + s.logger.Info("reset PR watch after branch switch", + zap.String("session_id", sessionID), + zap.String("new_branch", newBranch)) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go new file mode 100644 index 0000000000..a4863a8830 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go @@ -0,0 +1,122 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/orchestrator/watcher" + "github.com/kandev/kandev/internal/task/models" +) + +// Regression: when the agent renames or switches branches inside a session, +// handleBranchSwitched must persist the new branch to task_session_worktrees. +// Without this, downstream PR watch reconciliation keeps resolving the stale +// worktree_branch and fails to associate PRs created on the new branch. +func TestHandleBranchSwitched_UpdatesWorktreeBranch(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t1", "s1", "step1") + + // Seed a repository + worktree on the original branch. + rObj := &models.Repository{ + ID: "repo1", WorkspaceID: "ws1", Name: "myrepo", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "myrepo", + CreatedAt: now, UpdatedAt: now, + } + if err := testRepo.CreateRepository(ctx, rObj); err != nil { + t.Fatalf("create repository: %v", err) + } + wt := &models.TaskSessionWorktree{ + ID: "wt-s1", SessionID: "s1", + WorktreeID: "wtree-s1", RepositoryID: "repo1", + WorktreeBranch: "feature/a", CreatedAt: now, + } + if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("create worktree: %v", err) + } + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{} + svc.SetGitHubService(ghSvc) + + svc.handleBranchSwitched(ctx, watcher.GitEventData{ + TaskID: "t1", + SessionID: "s1", + BranchSwitch: &lifecycle.GitBranchSwitchData{ + PreviousBranch: "feature/a", + CurrentBranch: "feature/b", + BaseCommit: "deadbeef", + }, + }) + + // Verify the DB now reflects the new branch. + wts, err := testRepo.ListTaskSessionWorktrees(ctx, "s1") + if err != nil { + t.Fatalf("list worktrees: %v", err) + } + if len(wts) != 1 { + t.Fatalf("expected 1 worktree, got %d", len(wts)) + } + if wts[0].WorktreeBranch != "feature/b" { + t.Errorf("WorktreeBranch = %q, want %q", wts[0].WorktreeBranch, "feature/b") + } +} + +// 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 +// rename and stacked-PR workflows. +func TestHandleBranchSwitched_ResetsPRWatch(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + + testRepo := setupTestRepo(t) + seedSession(t, testRepo, "t1", "s1", "step1") + + rObj := &models.Repository{ + ID: "repo1", WorkspaceID: "ws1", Name: "myrepo", + SourceType: "provider", Provider: "github", + ProviderOwner: "myorg", ProviderName: "myrepo", + CreatedAt: now, UpdatedAt: now, + } + if err := testRepo.CreateRepository(ctx, rObj); err != nil { + t.Fatalf("create repository: %v", err) + } + wt := &models.TaskSessionWorktree{ + ID: "wt-s1", SessionID: "s1", + WorktreeID: "wtree-s1", RepositoryID: "repo1", + WorktreeBranch: "feature/a", CreatedAt: now, + } + if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + t.Fatalf("create worktree: %v", err) + } + + svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) + ghSvc := &mockGitHubService{ + prWatch: &github.PRWatch{ID: "watch-1", Branch: "feature/a", PRNumber: 42}, + } + svc.SetGitHubService(ghSvc) + + svc.handleBranchSwitched(ctx, watcher.GitEventData{ + TaskID: "t1", + SessionID: "s1", + BranchSwitch: &lifecycle.GitBranchSwitchData{ + PreviousBranch: "feature/a", + CurrentBranch: "feature/b", + BaseCommit: "deadbeef", + }, + }) + + if ghSvc.resetWatchCalls != 1 { + t.Errorf("expected 1 ResetPRWatch call, got %d", ghSvc.resetWatchCalls) + } + if ghSvc.resetWatchBranch != "feature/b" { + t.Errorf("reset watch branch = %q, want feature/b", ghSvc.resetWatchBranch) + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index fa6a68b3ba..7bc99170b5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -24,9 +24,9 @@ type GitHubService interface { CreatePRWatch(ctx context.Context, sessionID, taskID, owner, repo string, prNumber int, branch string) (*github.PRWatch, error) EnsurePRWatch(ctx context.Context, sessionID, taskID, owner, repo, branch string) (*github.PRWatch, error) GetPRWatchBySession(ctx context.Context, sessionID string) (*github.PRWatch, error) - UpdatePRWatchBranch(ctx context.Context, id, branch string) error UpdatePRWatchBranchIfSearching(ctx context.Context, id, branch string) error UpdatePRWatchPRNumber(ctx context.Context, id string, prNumber int) error + ResetPRWatch(ctx context.Context, id, branch string) error AssociatePRWithTask(ctx context.Context, taskID string, pr *github.PR) (*github.TaskPR, error) GetTaskPR(ctx context.Context, taskID string) (*github.TaskPR, error) ListActivePRWatches(ctx context.Context) ([]*github.PRWatch, error) diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 1357bcd41f..ce6269e246 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -22,6 +22,8 @@ type mockGitHubService struct { associateCalls int updateBranchCalls int updatePRNumberCalls int + resetWatchCalls int + resetWatchBranch string ensureWatchBranch string createWatchBranch string updatedBranch string @@ -49,11 +51,6 @@ func (m *mockGitHubService) AssociatePRWithTask(_ context.Context, _ string, _ * m.associateCalls++ return &github.TaskPR{}, nil } -func (m *mockGitHubService) UpdatePRWatchBranch(_ context.Context, _, branch string) error { - m.updateBranchCalls++ - m.updatedBranch = branch - return nil -} func (m *mockGitHubService) UpdatePRWatchBranchIfSearching(_ context.Context, _, branch string) error { m.updateBranchCalls++ m.updatedBranch = branch @@ -64,6 +61,11 @@ func (m *mockGitHubService) UpdatePRWatchPRNumber(_ context.Context, _ string, p m.updatedPRNumber = prNumber return nil } +func (m *mockGitHubService) ResetPRWatch(_ context.Context, _, branch string) error { + m.resetWatchCalls++ + m.resetWatchBranch = branch + return nil +} func (m *mockGitHubService) ListActivePRWatches(context.Context) ([]*github.PRWatch, error) { return nil, nil } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index c2637a9207..0d6312d29d 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -129,6 +129,7 @@ type sessionExecutorStore interface { UpdateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error UpdateTaskSessionBaseCommit(ctx context.Context, id string, baseCommitSHA string) error + UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error ClearSessionExecutionID(ctx context.Context, id string) error UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 480ec4465a..6c3af16e04 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -114,6 +114,7 @@ type SessionRepository interface { // SessionWorktreeRepository handles the task session↔worktree association. type SessionWorktreeRepository interface { CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error + UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskSessionWorktree, error) DeleteTaskSessionWorktree(ctx context.Context, id string) error diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 2a624a2739..4b8f4e6544 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -759,6 +759,19 @@ func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorkt return err } +// UpdateTaskSessionWorktreeBranch updates the cached worktree_branch for all +// worktrees belonging to a session. Called when a branch switch or rename is +// detected in the live workspace so downstream consumers (PR watch +// reconciliation, branch listings) see the current branch rather than the +// value captured at worktree creation. +func (r *Repository) UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, 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 = ? + `), branch, now, sessionID) + return err +} + func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT