diff --git a/apps/backend/internal/automation/run_retention.go b/apps/backend/internal/automation/run_retention.go index 1284b9ea98..c41d4b9b3a 100644 --- a/apps/backend/internal/automation/run_retention.go +++ b/apps/backend/internal/automation/run_retention.go @@ -50,15 +50,15 @@ const runWorktreeSweepWindow = 200 // Excluding the reclaimed ones makes the window slide: each firing sees only // runs that still cost disk, so the backlog drains across successive firings // and the steady state is a window one deep. The worktree row is reached -// through task_sessions because that is how a checkout is keyed — a task owns -// sessions, a session owns worktree rows — and `status <> 'deleted'` is the -// exact inverse of what worktree.Manager.ReleaseWorktreeReference writes when -// a checkout goes away. +// through the task environment because that is how a checkout is keyed — a +// task owns an environment, an environment owns worktree rows — and +// `status <> 'deleted'` is the exact inverse of what +// worktree.Manager.ReleaseWorktreeReference writes when a checkout goes away. const runHasLiveWorktreeSQL = ` EXISTS ( - SELECT 1 FROM task_session_worktrees tsw - JOIN task_sessions wts ON wts.id = tsw.session_id - WHERE wts.task_id = ar.task_id AND tsw.status <> ? + SELECT 1 FROM task_environment_repos ter + JOIN task_environments wte ON wte.id = ter.task_environment_id + WHERE wte.task_id = ar.task_id AND ter.status <> ? )` // PrunableRunTaskIDs names the tasks whose workspaces may be reclaimed, given a diff --git a/apps/backend/internal/automation/run_retention_test.go b/apps/backend/internal/automation/run_retention_test.go index 6b6780840c..f1b1c8fcf1 100644 --- a/apps/backend/internal/automation/run_retention_test.go +++ b/apps/backend/internal/automation/run_retention_test.go @@ -45,24 +45,22 @@ func seedTerminalRunSeries(t *testing.T, store *Store, automationID, prefix stri return taskIDs } -// seedLiveWorktree gives a task the session + worktree rows that say it still -// holds a checkout on disk. Retention offers only runs that have one, so every -// series has to carry them or the assertions below would all pass on an empty -// result. The session is deliberately not primary and not live — it is here to -// own the worktree row, not to make the task look busy. +// seedLiveWorktree gives a task the environment + repository rows that say it +// still holds a checkout on disk. Retention offers only runs that have one, so +// every series has to carry them or the assertions below would all pass on an +// empty result. func seedLiveWorktree(t *testing.T, store *Store, taskID string) { t.Helper() - sessionID := taskID + "-wt-session" if _, err := store.db.Exec( - `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 0, 'COMPLETED')`, - sessionID, taskID, + `INSERT INTO task_environments (id, task_id) VALUES (?, ?)`, + "env-"+taskID, taskID, ); err != nil { t.Fatal(err) } if _, err := store.db.Exec( - `INSERT INTO task_session_worktrees (id, session_id, worktree_id, worktree_path, status) + `INSERT INTO task_environment_repos (id, task_environment_id, worktree_id, worktree_path, status) VALUES (?, ?, ?, ?, 'active')`, - taskID+"-tsw", sessionID, "wt-"+taskID, "/workspaces/"+taskID, + taskID+"-ter", "env-"+taskID, "wt-"+taskID, "/workspaces/"+taskID, ); err != nil { t.Fatal(err) } @@ -73,7 +71,7 @@ func seedLiveWorktree(t *testing.T, store *Store, taskID string) { func reclaimWorktree(t *testing.T, store *Store, taskID string) { t.Helper() if _, err := store.db.Exec( - `UPDATE task_session_worktrees SET status = 'deleted', deleted_at = ? WHERE worktree_id = ?`, + `UPDATE task_environment_repos SET status = 'deleted', deleted_at = ? WHERE worktree_id = ?`, time.Now().UTC(), "wt-"+taskID, ); err != nil { t.Fatal(err) diff --git a/apps/backend/internal/automation/store_runs_test.go b/apps/backend/internal/automation/store_runs_test.go index e3f632a296..29894b67ad 100644 --- a/apps/backend/internal/automation/store_runs_test.go +++ b/apps/backend/internal/automation/store_runs_test.go @@ -25,9 +25,13 @@ func createTasksTable(t *testing.T, store *Store) { t.Fatal(err) } // PrunableRunTaskIDs only offers runs that still hold a checkout, and a - // checkout is reached task → session → worktree row. Without this table the - // query fails outright rather than returning nothing. - if _, err := store.db.Exec(`CREATE TABLE task_session_worktrees (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, worktree_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', worktree_path TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', deleted_at TIMESTAMP)`); err != nil { + // checkout is reached task → environment → environment-repository row. + // Without this table the query fails outright rather than returning + // nothing. + if _, err := store.db.Exec(`CREATE TABLE task_environments (id TEXT PRIMARY KEY, task_id TEXT NOT NULL)`); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`CREATE TABLE task_environment_repos (id TEXT PRIMARY KEY, task_environment_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', worktree_id TEXT NOT NULL DEFAULT '', worktree_path TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', deleted_at TIMESTAMP)`); err != nil { t.Fatal(err) } } diff --git a/apps/backend/internal/backendapp/branch_materializer.go b/apps/backend/internal/backendapp/branch_materializer.go index a58e80aed9..fc3a90a13c 100644 --- a/apps/backend/internal/backendapp/branch_materializer.go +++ b/apps/backend/internal/backendapp/branch_materializer.go @@ -77,7 +77,7 @@ func newBranchMaterializer(repo branchMaterializerRepo, mgr *worktree.Manager, l } // MaterializeBranch creates the worktree dir + persists the -// task_session_worktrees row for the just-inserted task_repositories row. +// task_environment_repos row for the just-inserted task_repositories row. // Designed to be idempotent: the worktree manager's reuse path catches a // rerun for the same (session, repo, branch_slug) triple and returns the // existing worktree. diff --git a/apps/backend/internal/backendapp/branch_materializer_test.go b/apps/backend/internal/backendapp/branch_materializer_test.go index 1543fc0c8e..794b91730d 100644 --- a/apps/backend/internal/backendapp/branch_materializer_test.go +++ b/apps/backend/internal/backendapp/branch_materializer_test.go @@ -322,7 +322,6 @@ func seedMaterializerTask(t *testing.T, ctx context.Context, repo *sqliterepo.Re ID: "env-1", TaskID: "task-1", TaskDirName: filepath.Base(taskRoot), WorkspacePath: primaryPath, - WorktreePath: primaryPath, ExecutorType: "worktree", Status: "ready", CreatedAt: now, UpdatedAt: now, }); err != nil { diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index f7c7d84817..c1ac43ad35 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -316,15 +316,26 @@ func TestResolveRepositoryIDForSubpathMatchesSanitizedRepositoryName(t *testing. }); err != nil { t.Fatalf("CreateTaskRepository: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "session-worktree-1", - SessionID: "session-1", - WorktreeID: "worktree-1", - RepositoryID: "repo-1", - WorktreePath: "/tmp/worktree", - WorktreeBranch: "feature/test", - BranchSlug: "test", - Position: 0, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-1", TaskID: "task-1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if _, err := sqlxDB.Exec(sqlxDB.Rebind( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), + "env-1", "session-1"); err != nil { + t.Fatalf("link session to env: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "session-worktree-1", + TaskEnvironmentID: "env-1", + RepositoryID: "repo-1", + WorktreeID: "worktree-1", + WorktreePath: "/tmp/worktree", + WorktreeBranch: "feature/test", + BranchSlug: "test", + Position: 0, }); err != nil { t.Fatalf("CreateTaskSessionWorktree: %v", err) } diff --git a/apps/backend/internal/backendapp/storage_inventory.go b/apps/backend/internal/backendapp/storage_inventory.go index a183d95ef6..0d44e8bd4e 100644 --- a/apps/backend/internal/backendapp/storage_inventory.go +++ b/apps/backend/internal/backendapp/storage_inventory.go @@ -59,11 +59,14 @@ func (i *storageInventory) LoadWorkspaceInventory(ctx context.Context) (workspac func (i *storageInventory) activeWorktreePaths(ctx context.Context) ([]string, error) { paths := make([]string, 0) - query := "SELECT w.worktree_path FROM task_session_worktrees w " + - "INNER JOIN task_sessions s ON s.id = w.session_id " + - "INNER JOIN tasks t ON t.id = s.task_id " + - "WHERE t.archived_at IS NULL AND w.status = 'active' " + - "AND w.deleted_at IS NULL AND w.worktree_path <> ''" + // Worktrees are task-owned: the inventory reads task_environment_repos + // through the owning environment's task row, so a zero-session task + // workspace is still protected from storage maintenance. + query := "SELECT ter.worktree_path FROM task_environment_repos ter " + + "INNER JOIN task_environments te ON te.id = ter.task_environment_id " + + "INNER JOIN tasks t ON t.id = te.task_id " + + "WHERE t.archived_at IS NULL AND ter.status = 'active' " + + "AND ter.deleted_at IS NULL AND ter.worktree_path <> ''" if err := i.reader.SelectContext(ctx, &paths, query); err != nil { return nil, err } diff --git a/apps/backend/internal/backendapp/storage_inventory_test.go b/apps/backend/internal/backendapp/storage_inventory_test.go index 95d47f0db3..12ab0afffa 100644 --- a/apps/backend/internal/backendapp/storage_inventory_test.go +++ b/apps/backend/internal/backendapp/storage_inventory_test.go @@ -71,15 +71,15 @@ func TestWorkspaceInventoryOnlyProtectsActiveTaskWorktrees(t *testing.T) { {taskID: "archived", path: "/tasks/archived_def/repo"}, {taskID: "deleted", path: "/tasks/deleted_ghi/repo"}, } { - sessionID := "session-" + item.taskID if _, err := database.Exec( - "INSERT INTO task_sessions (id, task_id) VALUES (?, ?)", sessionID, item.taskID, + "INSERT INTO task_environments (id, task_id, status) VALUES (?, ?, 'ready')", + "env-"+item.taskID, item.taskID, ); err != nil { - t.Fatalf("insert task session for %q: %v", item.taskID, err) + t.Fatalf("insert task environment for %q: %v", item.taskID, err) } if _, err := database.Exec( - "INSERT INTO task_session_worktrees (id, session_id, status, worktree_path) VALUES (?, ?, 'active', ?)", - "worktree-"+item.taskID, sessionID, item.path, + "INSERT INTO task_environment_repos (id, task_environment_id, status, worktree_path) VALUES (?, ?, 'active', ?)", + "worktree-"+item.taskID, "env-"+item.taskID, item.path, ); err != nil { t.Fatalf("insert task worktree for %q: %v", item.taskID, err) } @@ -273,9 +273,9 @@ func newContainerInventoryDatabase(t *testing.T) *sqlx.DB { state TEXT NOT NULL DEFAULT 'CREATED', task_environment_id TEXT NOT NULL DEFAULT '' ); - CREATE TABLE task_session_worktrees ( + CREATE TABLE task_environment_repos ( id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, + task_environment_id TEXT NOT NULL, status TEXT NOT NULL, worktree_path TEXT NOT NULL DEFAULT '', deleted_at TIMESTAMP diff --git a/apps/backend/internal/backendapp/worktree.go b/apps/backend/internal/backendapp/worktree.go index 57b868b433..5529fca202 100644 --- a/apps/backend/internal/backendapp/worktree.go +++ b/apps/backend/internal/backendapp/worktree.go @@ -156,10 +156,10 @@ func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, // For host-side worktrees we can push directly. Container/sandbox workspaces // would require an active agentctl client — not wired yet, surface a clear // error so the user knows to push manually. - if env.WorktreePath == "" { + worktreePath, branch := firstEnvironmentWorktree(env) + if worktreePath == "" { return fmt.Errorf("push-before-reset is not supported for this environment type; please push manually first") } - branch := strings.TrimSpace(env.WorktreeBranch) var args []string if branch == "" { args = []string{"push"} @@ -168,12 +168,12 @@ func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, // repos whose primary remote isn't called "origin" (e.g. fork // workflows with "upstream"/"github"). Fall back to "origin" only // when no upstream is set, matching the historical behaviour. - remote := detectBranchRemote(ctx, env.WorktreePath, branch) + remote := detectBranchRemote(ctx, worktreePath, branch) args = []string{"push", remote, branch} } out, runErr, execCtxErr := subproc.RunGitCombinedAfterAcquire(ctx, subproc.GitInteractive, pushBranchTimeout, func(execCtx context.Context) *exec.Cmd { cmd := subproc.NewGitCommand(execCtx, args...) - cmd.Dir = env.WorktreePath + cmd.Dir = worktreePath // Disable interactive credential prompts — without this, a missing // credential helper can hang waiting on stdin even with the timeout // above (signal delivery is gated behind the prompt read). @@ -182,7 +182,7 @@ func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, }) if runErr != nil || execCtxErr != nil { if errors.Is(execCtxErr, context.DeadlineExceeded) { - return fmt.Errorf("git push timed out after %s in %s (branch %q); push manually and retry", pushBranchTimeout, env.WorktreePath, branch) + return fmt.Errorf("git push timed out after %s in %s (branch %q); push manually and retry", pushBranchTimeout, worktreePath, branch) } if runErr == nil { runErr = execCtxErr @@ -192,6 +192,17 @@ func (a *environmentDestroyerAdapter) PushEnvironmentBranch(ctx context.Context, return nil } +// firstEnvironmentWorktree returns the path and branch of the environment's +// first repository row with a worktree path. +func firstEnvironmentWorktree(env *models.TaskEnvironment) (string, string) { + for _, repo := range env.Repos { + if repo != nil && repo.WorktreePath != "" { + return repo.WorktreePath, repo.WorktreeBranch + } + } + return "", "" +} + // defaultGitRemote is the conventional default remote name. Used as the // fallback when a branch has no configured upstream and at the comment level // when describing historical hard-coded behaviour. diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go index 57296abcf7..ae8910e2b5 100644 --- a/apps/backend/internal/editors/service/service_test.go +++ b/apps/backend/internal/editors/service/service_test.go @@ -145,7 +145,7 @@ func TestResolveSessionPath_WithWorktree(t *testing.T) { svc := &Service{} session := &taskmodels.TaskSession{ ID: "session-1", - Worktrees: []*taskmodels.TaskSessionWorktree{ + Worktrees: []*taskmodels.TaskEnvironmentRepo{ {WorktreePath: "/path/to/worktree"}, }, } @@ -162,7 +162,7 @@ func TestResolveSessionPath_EmptyWorktreePath(t *testing.T) { svc := &Service{} session := &taskmodels.TaskSession{ ID: "session-1", - Worktrees: []*taskmodels.TaskSessionWorktree{ + Worktrees: []*taskmodels.TaskEnvironmentRepo{ {WorktreePath: ""}, }, } @@ -176,7 +176,7 @@ func TestResolveSessionPath_SelectsWorktreeByID(t *testing.T) { svc := &Service{} session := &taskmodels.TaskSession{ ID: "session-1", - Worktrees: []*taskmodels.TaskSessionWorktree{ + Worktrees: []*taskmodels.TaskEnvironmentRepo{ {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"}, {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: "/path/to/repo-b"}, }, @@ -211,7 +211,7 @@ func TestResolveSessionPath_SelectedWorktreeEmptyPath(t *testing.T) { svc := &Service{} session := &taskmodels.TaskSession{ ID: "session-1", - Worktrees: []*taskmodels.TaskSessionWorktree{ + Worktrees: []*taskmodels.TaskEnvironmentRepo{ {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"}, {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: ""}, }, diff --git a/apps/backend/internal/orchestrator/event_handlers_automation_test.go b/apps/backend/internal/orchestrator/event_handlers_automation_test.go index 481715d145..89fb1f8f38 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation_test.go @@ -354,11 +354,21 @@ func TestHandleAutomationTurnComplete_FinalizesWithoutReapingTheWorktree(t *test ctx := context.Background() repo := setupTestRepo(t) seedAutomationRunSession(t, repo, "t-keep", "s-keep", "exec-keep") - require.NoError(t, repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - SessionID: "s-keep", - WorktreeID: "wt-keep", - RepositoryID: "repo-1", - WorktreePath: "/tmp/kandev/t-keep", + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-keep", TaskID: "t-keep", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "s-keep") + require.NoError(t, err) + session.TaskEnvironmentID = "env-keep" + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + require.NoError(t, repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-keep", + RepositoryID: "repo-1", + WorktreeID: "wt-keep", + WorktreePath: "/tmp/kandev/t-keep", })) mgr := &mockAgentManager{} @@ -366,7 +376,7 @@ func TestHandleAutomationTurnComplete_FinalizesWithoutReapingTheWorktree(t *test svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), mgr) svc.SetAutomationService(autoSvc) - session, err := repo.GetTaskSession(ctx, "s-keep") + session, err = repo.GetTaskSession(ctx, "s-keep") require.NoError(t, err) handled := svc.handleAutomationTurnComplete(ctx, "t-keep", "s-keep", session, "end_turn", false, "") require.True(t, handled) @@ -602,7 +612,7 @@ func (f *fakeWorktreeReaper) releaseRow(worktreeID string) { return } _, _ = f.db.Exec( - `UPDATE task_session_worktrees SET status = ?, deleted_at = ? WHERE worktree_id = ?`, + `UPDATE task_environment_repos SET status = ?, deleted_at = ? WHERE worktree_id = ?`, worktree.StatusDeleted, time.Now().UTC(), worktreeID, ) } @@ -685,10 +695,19 @@ func (f *automationRetentionFixture) seedSessionWorktree(t *testing.T, taskID st sessionID, taskID, now, now) require.NoError(t, err) _, err = f.db.Exec( - `INSERT INTO task_session_worktrees - (id, session_id, worktree_id, repository_id, worktree_path, status, created_at, updated_at) + `INSERT INTO task_environments (id, task_id, executor_type, status, workspace_path, created_at, updated_at) + VALUES (?, ?, 'worktree', 'ready', ?, ?, ?)`, + "env-"+taskID, taskID, f.workspacePath(taskID), now, now) + require.NoError(t, err) + _, err = f.db.Exec( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`, + "env-"+taskID, sessionID) + require.NoError(t, err) + _, err = f.db.Exec( + `INSERT INTO task_environment_repos + (id, task_environment_id, worktree_id, repository_id, worktree_path, status, created_at, updated_at) VALUES (?, ?, ?, 'repo-1', ?, 'active', ?, ?)`, - taskID+"-tsw", sessionID, "wt-"+taskID, f.workspacePath(taskID), now, now) + taskID+"-ter", "env-"+taskID, "wt-"+taskID, f.workspacePath(taskID), now, now) require.NoError(t, err) } diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index dc31d6e432..006244a764 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -823,7 +823,7 @@ func (s *Service) updateBranchSwitchWorktreeSnapshot(ctx context.Context, sessio return s.persistBranchSwitchWorktrees(ctx, scoped, sessionID, branch, matched) } -func matchingBranchSwitchWorktrees(worktrees []*models.TaskSessionWorktree, repositoryName string) map[string]string { +func matchingBranchSwitchWorktrees(worktrees []*models.TaskEnvironmentRepo, repositoryName string) map[string]string { matched := make(map[string]string) for _, worktree := range worktrees { if worktree == nil || worktree.RepositoryID == "" { diff --git a/apps/backend/internal/orchestrator/event_handlers_git_test.go b/apps/backend/internal/orchestrator/event_handlers_git_test.go index 02de6b45b2..589b96a247 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_git_test.go @@ -36,12 +36,26 @@ func TestHandleBranchSwitched_UpdatesWorktreeBranch(t *testing.T) { if err := testRepo.CreateRepository(ctx, rObj); err != nil { t.Fatalf("create repository: %v", err) } - wt := &models.TaskSessionWorktree{ - ID: "wt-s1", SessionID: "s1", + if err := testRepo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-s1", TaskID: "t1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("create environment: %v", err) + } + session, err := testRepo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-s1" + if err := testRepo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + wt := &models.TaskEnvironmentRepo{ + ID: "wt-s1", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-s1", RepositoryID: "repo1", WorktreeBranch: "feature/a", CreatedAt: now, } - if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + if err := testRepo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("create worktree: %v", err) } @@ -84,12 +98,21 @@ func TestHandleBranchSwitched_RepositoryScopedUpdateKeepsSiblingBranch(t *testin } { 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", + + require.NoError(t, testRepo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-multi", TaskID: "t-multi", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + })) + multiSession, err := testRepo.GetTaskSession(ctx, "s-multi") + require.NoError(t, err) + multiSession.TaskEnvironmentID = "env-multi" + require.NoError(t, testRepo.UpdateTaskSession(ctx, multiSession)) + require.NoError(t, testRepo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt-multi-backend", TaskEnvironmentID: "env-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", + require.NoError(t, testRepo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt-multi-frontend", TaskEnvironmentID: "env-multi", WorktreeID: "worktree-frontend", RepositoryID: "repo-frontend", WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "feature/frontend-old", Position: 1, CreatedAt: now, })) @@ -124,12 +147,21 @@ func TestHandleBranchSwitched_UnknownRepositoryNameDoesNotOverwriteSiblings(t *t } { 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", + + require.NoError(t, testRepo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-multi-unknown", TaskID: "t-multi-unknown", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + })) + multiUnknownSession, err := testRepo.GetTaskSession(ctx, "s-multi-unknown") + require.NoError(t, err) + multiUnknownSession.TaskEnvironmentID = "env-multi-unknown" + require.NoError(t, testRepo.UpdateTaskSession(ctx, multiUnknownSession)) + require.NoError(t, testRepo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt-unknown-backend", TaskEnvironmentID: "env-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", + require.NoError(t, testRepo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt-unknown-frontend", TaskEnvironmentID: "env-multi-unknown", WorktreeID: "worktree-unknown-frontend", RepositoryID: "repo-unknown-frontend", WorktreePath: filepath.Join(taskRoot, "frontend"), WorktreeBranch: "feature/frontend-old", Position: 1, CreatedAt: now, })) @@ -171,12 +203,26 @@ func TestHandleBranchSwitched_ResetsPRWatch(t *testing.T) { if err := testRepo.CreateRepository(ctx, rObj); err != nil { t.Fatalf("create repository: %v", err) } - wt := &models.TaskSessionWorktree{ - ID: "wt-s1", SessionID: "s1", + if err := testRepo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-s1", TaskID: "t1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("create environment: %v", err) + } + session, err := testRepo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-s1" + if err := testRepo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + wt := &models.TaskEnvironmentRepo{ + ID: "wt-s1", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-s1", RepositoryID: "repo1", WorktreeBranch: "feature/a", CreatedAt: now, } - if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + if err := testRepo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("create worktree: %v", err) } diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index 48f5b0c6fa..ac973f17f5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -959,7 +959,7 @@ func (s *Service) resolveSessionWatchTargets( return targets } -// targetsFromMultiBranchWorktrees emits one target per task_session_worktrees +// targetsFromMultiBranchWorktrees emits one target per environment repository // row, but ONLY when at least one repository has more than one worktree — // the multi-branch case. The worktree's actual branch (worktree_branch) is // the authoritative key because the worktree manager may suffix a diff --git a/apps/backend/internal/orchestrator/event_handlers_github_test.go b/apps/backend/internal/orchestrator/event_handlers_github_test.go index 11232357cb..1b38c8bb52 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_github_test.go @@ -514,15 +514,29 @@ func TestCheckSessionPR(t *testing.T) { } // Add worktree with branch to the session - wt := &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "s1", - WorktreeID: "wtree1", - RepositoryID: "repo1", - WorktreeBranch: branch, - CreatedAt: now, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "t1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("failed to create environment: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + wt := &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env1", + WorktreeID: "wtree1", + RepositoryID: "repo1", + WorktreeBranch: branch, + CreatedAt: now, + } + if err := repo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -966,7 +980,7 @@ func TestListTasksNeedingPRWatch(t *testing.T) { seedFullSession := func(t *testing.T, repo interface { CreateRepository(ctx context.Context, r *models.Repository) error CreateTaskRepository(ctx context.Context, tr *models.TaskRepository) error - CreateTaskSessionWorktree(ctx context.Context, wt *models.TaskSessionWorktree) error + CreateTaskEnvironmentRepo(ctx context.Context, wt *models.TaskEnvironmentRepo) error }, taskID, sessionID, branch, repoID string) { t.Helper() rObj := &models.Repository{ @@ -984,16 +998,36 @@ func TestListTasksNeedingPRWatch(t *testing.T) { } _ = repo.CreateTaskRepository(ctx, tr) - wt := &models.TaskSessionWorktree{ - ID: "wt-" + sessionID, SessionID: sessionID, + wt := &models.TaskEnvironmentRepo{ + ID: "wt-" + sessionID, TaskEnvironmentID: "env-" + sessionID, WorktreeID: "wtree-" + sessionID, RepositoryID: repoID, WorktreeBranch: branch, CreatedAt: now, } - if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + if err := repo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("failed to create worktree: %v", err) } } + // seedEnvironment creates the environment row a seeded worktree references + // and links the session to it. + seedEnvironment := func(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID string) { + t.Helper() + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-" + sessionID, TaskID: taskID, ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("failed to create environment: %v", err) + } + session, err := repo.GetTaskSession(ctx, sessionID) + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-" + sessionID + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + } + // seedTask creates a second task and session in an already-seeded repo. seedTask := func(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID string) { t.Helper() @@ -1018,7 +1052,9 @@ func TestListTasksNeedingPRWatch(t *testing.T) { testRepo := setupTestRepo(t) seedSession(t, testRepo, "t1", "s1", "step1") seedTask(t, testRepo, "t2", "s2") + seedEnvironment(t, testRepo, "t1", "s1") seedFullSession(t, testRepo, "t1", "s1", "feature-a", "repo1") + seedEnvironment(t, testRepo, "t2", "s2") seedFullSession(t, testRepo, "t2", "s2", "feature-b", "repo1") svc := createTestService(testRepo, newMockStepGetter(), newMockTaskRepo()) @@ -1054,6 +1090,7 @@ func TestListTasksNeedingPRWatch(t *testing.T) { t.Run("excludes sessions on archived tasks", func(t *testing.T) { testRepo := setupTestRepo(t) seedSession(t, testRepo, "t1", "s1", "step1") + seedEnvironment(t, testRepo, "t1", "s1") seedFullSession(t, testRepo, "t1", "s1", "feature-a", "repo1") // Archive the task. @@ -1123,12 +1160,26 @@ func TestListTasksNeedingPRWatch(t *testing.T) { t.Fatalf("link %s: %v", l.ID, err) } } - worktrees := []*models.TaskSessionWorktree{ - {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, - {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + if err := testRepo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-s1", TaskID: "t1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("create environment: %v", err) + } + session, err := testRepo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-s1" + if err := testRepo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + worktrees := []*models.TaskEnvironmentRepo{ + {ID: "wt-1", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, } for _, wt := range worktrees { - if err := testRepo.CreateTaskSessionWorktree(ctx, wt); err != nil { + if err := testRepo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("worktree %s: %v", wt.ID, err) } } @@ -1204,11 +1255,25 @@ func TestEnsureSessionPRWatch_MultiRepo(t *testing.T) { t.Fatalf("link: %v", err) } } - for _, wt := range []*models.TaskSessionWorktree{ - {ID: "wt-1", SessionID: "s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, - {ID: "wt-2", SessionID: "s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-s1", TaskID: "t1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("create environment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-s1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + for _, wt := range []*models.TaskEnvironmentRepo{ + {ID: "wt-1", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-1", RepositoryID: "repo-front", WorktreeBranch: "feat/frontend", CreatedAt: now}, + {ID: "wt-2", TaskEnvironmentID: "env-s1", WorktreeID: "wtree-2", RepositoryID: "repo-back", WorktreeBranch: "feat/backend", CreatedAt: now}, } { - if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { + if err := repo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { t.Fatalf("worktree: %v", err) } } diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index f25edd42ad..2e77927c91 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -59,9 +59,8 @@ type executorStore interface { SetSessionPrimary(ctx context.Context, sessionID string) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) - // Session worktree - CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error - ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + // Session worktree projections (owned by the task environment) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) // Repository entity GetRepository(ctx context.Context, id string) (*models.Repository, error) // Executor diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 3426d9a299..1ac2860165 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -47,12 +47,6 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen if req.UseWorktree { e.reuseExistingRepositoryWorktrees(ctx, req, env) } - if req.WorktreeID == "" && env.WorktreeID != "" && req.UseWorktree && !hasBranchScopedEnvironmentWorktrees(env) { - req.WorktreeID = env.WorktreeID - e.logger.Info("reusing existing task environment worktree", - zap.String("task_id", req.TaskID), - zap.String("worktree_id", env.WorktreeID)) - } if env.ContainerID != "" || env.SandboxID != "" { metadata := ensureLaunchMetadata(req) @@ -66,10 +60,11 @@ func (e *Executor) reuseExistingEnvironment(ctx context.Context, req *LaunchAgen // Forward the persisted feature branch so the in-sandbox prepare script // can re-create or reuse it. Applies to every clone-based remote executor - // (the preparer is responsible for stamping env.WorktreeBranch in the - // first place); the host-side worktree path uses req.WorktreeID instead. - if env.WorktreeBranch != "" && isContainerizedExecutor(req.ExecutorType) { - ensureLaunchMetadata(req)[lifecycle.MetadataKeyWorktreeBranch] = env.WorktreeBranch + // (the preparer is responsible for stamping the environment repository + // row's worktree_branch in the first place); the host-side worktree path + // uses req.WorktreeID instead. + if branch := environmentWorktreeBranch(env); branch != "" && isContainerizedExecutor(req.ExecutorType) { + ensureLaunchMetadata(req)[lifecycle.MetadataKeyWorktreeBranch] = branch } if env.ID == "" { @@ -238,20 +233,19 @@ func launchRepoBranchIdentitySlug(spec RepoSpec) string { return worktree.SanitizeBranchSlug(spec.BranchSlug) } -func (e *Executor) environmentRepoWorktreeIDs(req *LaunchAgentRequest, env *models.TaskEnvironment) map[repositoryWorktreeKey]string { - result := make(map[repositoryWorktreeKey]string) - if env.WorktreeID != "" && !hasBranchScopedEnvironmentWorktrees(env) { - repoID := env.RepositoryID - if repoID == "" { - repoID = req.RepositoryID - } - if repoID != "" { - result[repositoryWorktreeKey{ - repositoryID: repoID, - branchSlug: "", - }] = env.WorktreeID +// environmentWorktreeBranch returns the first non-empty worktree branch +// recorded on the environment's repository rows. +func environmentWorktreeBranch(env *models.TaskEnvironment) string { + for _, repo := range env.Repos { + if repo != nil && repo.WorktreeBranch != "" { + return repo.WorktreeBranch } } + return "" +} + +func (e *Executor) environmentRepoWorktreeIDs(req *LaunchAgentRequest, env *models.TaskEnvironment) map[repositoryWorktreeKey]string { + result := make(map[repositoryWorktreeKey]string) for _, repo := range env.Repos { if repo.RepositoryID == "" || repo.WorktreeID == "" { continue @@ -274,6 +268,9 @@ func hasBranchScopedEnvironmentWorktrees(env *models.TaskEnvironment) bool { } func (e *Executor) latestSessionWorktreeIDsForEnvironment(ctx context.Context, taskID, envID string) map[repositoryWorktreeKey]string { + if e.repo == nil { + return nil + } sessions, err := e.repo.ListTaskSessions(ctx, taskID) if err != nil { e.logger.Warn("failed to list sessions for per-repo worktree reuse", @@ -311,7 +308,7 @@ func (e *Executor) latestSessionWorktreeIDsForEnvironment(ctx context.Context, t return nil } -func sessionWorktreeIDsByKey(worktrees []*models.TaskSessionWorktree) map[repositoryWorktreeKey]string { +func sessionWorktreeIDsByKey(worktrees []*models.TaskEnvironmentRepo) map[repositoryWorktreeKey]string { result := make(map[repositoryWorktreeKey]string, len(worktrees)) for _, wt := range worktrees { if wt.RepositoryID == "" || wt.WorktreeID == "" { diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index 88b7b89d3d..e62ab94457 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -36,9 +36,11 @@ func TestReuseExistingEnvironment_NilEnv(t *testing.T) { func TestReuseExistingEnvironment_WorktreeReuse(t *testing.T) { e := newEnvTestExecutor(t) - req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: true} + req := &LaunchAgentRequest{TaskID: "task-1", RepositoryID: "repo-1", UseWorktree: true} env := &models.TaskEnvironment{ - WorktreeID: "wt-1", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-1", WorktreeID: "wt-1", BranchSlug: ""}, + }, } e.reuseExistingEnvironment(context.Background(), req, env) @@ -95,7 +97,6 @@ func TestReuseExistingEnvironment_SkipsReuseOnExecutorTypeMismatch(t *testing.T) env := &models.TaskEnvironment{ ExecutorType: "sprites", ContainerID: "container-abc", - WorktreeID: "wt-1", } e.reuseExistingEnvironment(context.Background(), req, env) @@ -114,9 +115,7 @@ func TestReuseExistingEnvironment_SkipsReuseOnExecutorTypeMismatch(t *testing.T) func TestReuseExistingEnvironment_WorktreeSkippedWhenNotRequested(t *testing.T) { e := newEnvTestExecutor(t) req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: false} - env := &models.TaskEnvironment{ - WorktreeID: "wt-1", - } + env := &models.TaskEnvironment{} e.reuseExistingEnvironment(context.Background(), req, env) @@ -146,9 +145,11 @@ func TestReuseExistingEnvironment_DockerBranchReuse(t *testing.T) { e := newEnvTestExecutor(t) req := &LaunchAgentRequest{TaskID: "task-1", ExecutorType: "local_docker"} env := &models.TaskEnvironment{ - ExecutorType: "local_docker", - ContainerID: "container-abc", - WorktreeBranch: "feature/existing-task-abc", + ExecutorType: "local_docker", + ContainerID: "container-abc", + Repos: []*models.TaskEnvironmentRepo{ + {WorktreeBranch: "feature/existing-task-abc"}, + }, } e.reuseExistingEnvironment(context.Background(), req, env) @@ -386,10 +387,12 @@ func TestReuseExistingEnvironment_SandboxReuse(t *testing.T) { func TestReuseExistingEnvironment_WorktreeAndContainer(t *testing.T) { e := newEnvTestExecutor(t) - req := &LaunchAgentRequest{TaskID: "task-1", UseWorktree: true} + req := &LaunchAgentRequest{TaskID: "task-1", RepositoryID: "repo-1", UseWorktree: true} env := &models.TaskEnvironment{ - WorktreeID: "wt-1", ContainerID: "container-abc", + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-1", WorktreeID: "wt-1"}, + }, } e.reuseExistingEnvironment(context.Background(), req, env) diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 07889a14f5..9993f5055e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -1212,7 +1212,6 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m e.cleanupUnstartedExecutionAfterPersistError(ctx, sessionID, resp.AgentExecutionID, err) return nil, err } - e.persistWorktreeAssociation(ctx, task.ID, session, repoInfo.RepositoryID, resp) sessionState := v1.TaskSessionStateCreated if startAgent { @@ -1786,22 +1785,14 @@ func (e *Executor) persistTaskEnvironment( if existingEnv != nil { // agent_execution_id is no longer stored on task_environments — the column // is being dropped (executors_running is the single source of truth). - // Status, worktree, and container fields are still env-row-owned. + // Status, workspace, and container fields are still env-row-owned; the + // physical worktree lives on task_environment_repos. existingEnv.Status = models.TaskEnvironmentStatusReady - // Refresh worktree + workspace + container/sandbox fields. The original - // update branch only touched AgentExecutionID/Status, so envs created - // with empty paths (e.g. before the worktree resolved) stayed - // permanently broken. Sandbox ID gets refreshed too in case a fallback - // created a new sprite. - if resp.WorktreeID != "" { - existingEnv.WorktreeID = resp.WorktreeID - } - if resp.WorktreePath != "" { - existingEnv.WorktreePath = resp.WorktreePath - } - if resp.WorktreeBranch != "" { - existingEnv.WorktreeBranch = resp.WorktreeBranch - } + // Refresh workspace + container/sandbox fields. The original update + // branch only touched AgentExecutionID/Status, so envs created with + // empty paths (e.g. before the worktree resolved) stayed permanently + // broken. Sandbox ID gets refreshed too in case a fallback created a + // new sprite. if workspacePath != "" { existingEnv.WorkspacePath = workspacePath } @@ -1824,33 +1815,30 @@ func (e *Executor) persistTaskEnvironment( zap.Error(err)) } session.TaskEnvironmentID = existingEnv.ID - // Persist per-repo rows for multi-repo launches that didn't have them yet. - e.persistTaskEnvironmentRepos(ctx, existingEnv.ID, resp.Worktrees) + // Persist per-repo rows for launches that didn't have them yet. The + // environment-repository rows are the only physical-worktree record, + // so single-repo launches write one row here too. + e.persistTaskEnvironmentRepos(ctx, existingEnv.ID, environmentReposForLaunch(req, resp)) return } env := &models.TaskEnvironment{ ID: session.TaskEnvironmentID, TaskID: taskID, - RepositoryID: req.RepositoryID, ExecutorType: req.ExecutorType, ExecutorID: execCfg.ExecutorID, ExecutorProfileID: session.ExecutorProfileID, // AgentExecutionID is intentionally not set here — see executors_running // for the active execution per session. - Status: models.TaskEnvironmentStatusReady, - WorktreeID: resp.WorktreeID, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - WorkspacePath: workspacePath, - ContainerID: resp.ContainerID, - TaskDirName: req.TaskDirName, - SandboxID: extractSandboxID(resp.Metadata), - } - // Embed per-repo rows in the same create transaction when multi-repo. - if len(resp.Worktrees) > 0 { - env.Repos = buildTaskEnvironmentRepos(resp.Worktrees) + Status: models.TaskEnvironmentStatusReady, + WorkspacePath: workspacePath, + ContainerID: resp.ContainerID, + TaskDirName: req.TaskDirName, + SandboxID: extractSandboxID(resp.Metadata), } + // Embed per-repo rows in the same create transaction. Single-repo + // launches produce one row so the worktree identity is always recorded. + env.Repos = environmentReposForLaunch(req, resp) if err := e.repo.CreateTaskEnvironment(ctx, env); err != nil { e.logger.Warn("failed to create task environment", zap.String("task_id", taskID), @@ -1860,6 +1848,26 @@ func (e *Executor) persistTaskEnvironment( session.TaskEnvironmentID = env.ID } +// environmentReposForLaunch returns the environment-repository rows for a +// launch: one per multi-repo worktree result, or a single row carrying the +// single-repo worktree identity. +func environmentReposForLaunch(req *LaunchAgentRequest, resp *LaunchAgentResponse) []*models.TaskEnvironmentRepo { + if len(resp.Worktrees) > 0 { + return buildTaskEnvironmentRepos(resp.Worktrees) + } + if resp.WorktreeID == "" { + return nil + } + return []*models.TaskEnvironmentRepo{{ + RepositoryID: req.RepositoryID, + BranchSlug: req.BranchSlug, + WorktreeID: resp.WorktreeID, + WorktreePath: resp.WorktreePath, + WorktreeBranch: resp.WorktreeBranch, + Position: 0, + }} +} + // buildTaskEnvironmentRepos converts per-repo worktree results into env-repo rows. // TaskEnvironmentID is left blank — it is set by the env Create transaction. func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnvironmentRepo { @@ -1882,8 +1890,8 @@ func buildTaskEnvironmentRepos(worktrees []RepoWorktreeResult) []*models.TaskEnv // Used when an existing environment is reused (resume / re-launch on the same // task), including cases where stale or legacy rows need the successful launch // result written back for the next handoff. -func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, worktrees []RepoWorktreeResult) { - if envID == "" || len(worktrees) == 0 { +func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string, repos []*models.TaskEnvironmentRepo) { + if envID == "" || len(repos) == 0 { return } existing, err := e.repo.ListTaskEnvironmentRepos(ctx, envID) @@ -1902,7 +1910,7 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string legacyFlatByRepo[row.RepositoryID] = row } } - for i, w := range worktrees { + for i, w := range repos { if w.RepositoryID == "" { continue } @@ -1938,7 +1946,7 @@ func (e *Executor) persistTaskEnvironmentRepos(ctx context.Context, envID string } } -func (e *Executor) refreshTaskEnvironmentRepo(ctx context.Context, row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) { +func (e *Executor) refreshTaskEnvironmentRepo(ctx context.Context, row, w *models.TaskEnvironmentRepo, position int) { if !taskEnvironmentRepoNeedsRefresh(row, w, position) { return } @@ -1957,7 +1965,7 @@ func (e *Executor) refreshTaskEnvironmentRepo(ctx context.Context, row *models.T } } -func taskEnvironmentRepoNeedsRefresh(row *models.TaskEnvironmentRepo, w RepoWorktreeResult, position int) bool { +func taskEnvironmentRepoNeedsRefresh(row, w *models.TaskEnvironmentRepo, position int) bool { return row.BranchSlug != w.BranchSlug || row.WorktreeID != w.WorktreeID || row.WorktreePath != w.WorktreePath || diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index bc7ae78c0d..bcc6f5aa56 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -555,7 +555,10 @@ func workspaceFromTaskEnvironment(env *models.TaskEnvironment) string { if workDir := strings.TrimSpace(env.WorkspacePath); workDir != "" { return workDir } - return strings.TrimSpace(env.WorktreePath) + if len(env.Repos) > 0 && env.Repos[0] != nil { + return strings.TrimSpace(env.Repos[0].WorktreePath) + } + return "" } // SwitchModel switches the model for a running session. It first attempts an diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 23d99b14cf..979b47aea1 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -260,7 +260,6 @@ type mockRepository struct { executorsRunning map[string]*models.ExecutorRunning taskEnvironments map[string]*models.TaskEnvironment taskEnvironmentRepos map[string][]*models.TaskEnvironmentRepo // env_id → rows - sessionWorktrees []*models.TaskSessionWorktree // Optional hook to inject behavior into GetTaskSession (e.g. simulate a // transient DB error); if nil, the default map lookup is used. @@ -444,7 +443,7 @@ func cloneMockTaskSession(session *models.TaskSession) *models.TaskSession { clone.EnvironmentSnapshot = cloneMockSessionMap(session.EnvironmentSnapshot) clone.RepositorySnapshot = cloneMockSessionMap(session.RepositorySnapshot) if session.Worktrees != nil { - clone.Worktrees = make([]*models.TaskSessionWorktree, len(session.Worktrees)) + clone.Worktrees = make([]*models.TaskEnvironmentRepo, len(session.Worktrees)) for i, worktree := range session.Worktrees { if worktree == nil { continue @@ -558,11 +557,6 @@ func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *mod return nil } -func (m *mockRepository) CreateTaskSessionWorktree(_ context.Context, worktree *models.TaskSessionWorktree) error { - m.sessionWorktrees = append(m.sessionWorktrees, worktree) - return nil -} - func (m *mockRepository) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { return nil } @@ -925,21 +919,21 @@ func (m *mockRepository) GetLastAgentMessage(_ context.Context, _ string) (strin } // Task Session Worktree operations -func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { - var out []*models.TaskSessionWorktree - for _, wt := range m.sessionWorktrees { - if wt.SessionID == sessionID { - out = append(out, wt) - } +func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) { + if m.taskEnvironmentRepos == nil { + return nil, nil } - return out, nil + // Resolve session → environment, then return that environment's repos. + for _, session := range m.sessions { + if session == nil || session.ID != sessionID { + continue + } + return m.taskEnvironmentRepos[session.TaskEnvironmentID], nil + } + return nil, nil } -func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskSessionWorktree, error) { - return make(map[string][]*models.TaskSessionWorktree), nil -} -func (m *mockRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { return nil } -func (m *mockRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { - return nil +func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskEnvironmentRepo, error) { + return make(map[string][]*models.TaskEnvironmentRepo), nil } // Git Snapshot operations diff --git a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go index 8b86f6f9fd..ab90018e3e 100644 --- a/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_multi_repo_test.go @@ -293,12 +293,13 @@ func TestLaunchPreparedSession_MultiRepo_PersistsPerRepoEnvironmentAndWorktreeRo t.Errorf("expected 2 task_environment_repos, got %d", got) } - // Two TaskSessionWorktree rows, one per repo. - if len(repo.sessionWorktrees) != 2 { - t.Fatalf("expected 2 session_worktree rows, got %d", len(repo.sessionWorktrees)) + // Two task_environment_repos rows, one per repo. + envRepos := repo.taskEnvironmentRepos[envID] + if len(envRepos) != 2 { + t.Fatalf("expected 2 task_environment_repos rows, got %d", len(envRepos)) } repoIDsSeen := map[string]bool{} - for _, w := range repo.sessionWorktrees { + for _, w := range envRepos { repoIDsSeen[w.RepositoryID] = true } if !repoIDsSeen["repo-front"] || !repoIDsSeen["repo-back"] { @@ -318,7 +319,6 @@ func TestLaunchPreparedSession_MultiRepo_ReusesPerRepoWorktreeIDsFromEnvironment TaskID: taskID, ExecutorType: string(models.ExecutorTypeWorktree), Status: models.TaskEnvironmentStatusReady, - WorktreeID: "wt-front", Repos: []*models.TaskEnvironmentRepo{ { TaskEnvironmentID: "env-existing", @@ -454,18 +454,18 @@ func TestReuseExistingRepositoryWorktrees_EnvironmentRowsWinOverSessionRows(t *t StartedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), } - repo.sessionWorktrees = append(repo.sessionWorktrees, - &models.TaskSessionWorktree{ - SessionID: "session-prev", - RepositoryID: "repo-a", - BranchSlug: "main", - WorktreeID: "wt-session-a", + repo.taskEnvironmentRepos[envID] = append(repo.taskEnvironmentRepos[envID], + &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: "repo-a", + BranchSlug: "main", + WorktreeID: "wt-session-a", }, - &models.TaskSessionWorktree{ - SessionID: "session-prev", - RepositoryID: "repo-b", - BranchSlug: "feature", - WorktreeID: "wt-session-b", + &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: "repo-b", + BranchSlug: "feature", + WorktreeID: "wt-session-b", }, ) exec := newTestExecutor(t, &mockAgentManager{}, repo) @@ -518,15 +518,17 @@ func TestReuseExistingRepositoryWorktrees_LegacyFlatEnvWorktreeFeedsFlatBranchSp }, } env := &models.TaskEnvironment{ - ID: "env-legacy-flat", - RepositoryID: "repo-kandev", - WorktreeID: "wt-legacy-flat", + ID: "env-legacy-flat", + Repos: []*models.TaskEnvironmentRepo{{ + RepositoryID: "repo-kandev", + WorktreeID: "wt-legacy-flat", + }}, } exec.reuseExistingRepositoryWorktrees(context.Background(), req, env) if req.Repositories[0].WorktreeID != "wt-legacy-flat" { - t.Fatalf("flat repo WorktreeID = %q, want legacy top-level wt-legacy-flat", req.Repositories[0].WorktreeID) + t.Fatalf("repo WorktreeID = %q, want wt-legacy-flat", req.Repositories[0].WorktreeID) } if req.Repositories[1].WorktreeID != "" { t.Fatalf("nested repo WorktreeID = %q, want empty", req.Repositories[1].WorktreeID) @@ -582,9 +584,7 @@ func TestReuseExistingEnvironment_SingleRepoUsesBranchScopedEnvRow(t *testing.T) UseWorktree: true, } env := &models.TaskEnvironment{ - ID: "env-single-branch-row", - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", + ID: "env-single-branch-row", Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, {RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"}, @@ -610,9 +610,7 @@ func TestReuseExistingEnvironment_SingleRepoBranchMatchKeepsScopedRepoSpec(t *te UseWorktree: true, } env := &models.TaskEnvironment{ - ID: "env-single-branch-row", - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", + ID: "env-single-branch-row", Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, {RepositoryID: "repo-kandev", BranchSlug: "feature-x", WorktreeID: "wt-feature"}, @@ -655,9 +653,7 @@ func TestReuseExistingEnvironment_SingleRepoUsesDefaultBranchScopedEnvRow(t *tes UseWorktree: true, } env := &models.TaskEnvironment{ - ID: "env-single-default-branch-row", - RepositoryID: "repo-kandev", - WorktreeID: "wt-feature", + ID: "env-single-default-branch-row", Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, }, @@ -680,9 +676,7 @@ func TestReuseExistingEnvironment_SingleRepoDoesNotFallBackToWrongScopedEnvWorkt UseWorktree: true, } env := &models.TaskEnvironment{ - ID: "env-single-no-match", - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", + ID: "env-single-no-match", Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, }, @@ -709,9 +703,7 @@ func TestReuseExistingEnvironment_SingleRepoUnmatchedScopedEnvUsesBranchPathSlug UseWorktree: true, } env := &models.TaskEnvironment{ - ID: "env-single-new-scoped-branch", - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", + ID: "env-single-new-scoped-branch", Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, }, @@ -758,11 +750,11 @@ func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchWhenEnvIsSc StartedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), } - repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{ - SessionID: "session-prev", - RepositoryID: "repo-kandev", - BranchSlug: "", - WorktreeID: "wt-stale-empty-branch", + repo.taskEnvironmentRepos[envID] = append(repo.taskEnvironmentRepos[envID], &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: "repo-kandev", + BranchSlug: "", + WorktreeID: "wt-stale-empty-branch", }) exec := newTestExecutor(t, &mockAgentManager{}, repo) req := &LaunchAgentRequest{ @@ -772,9 +764,7 @@ func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchWhenEnvIsSc UseWorktree: true, } env := &models.TaskEnvironment{ - ID: envID, - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", + ID: envID, Repos: []*models.TaskEnvironmentRepo{ {RepositoryID: "repo-kandev", BranchSlug: "main", WorktreeID: "wt-main"}, }, @@ -799,11 +789,11 @@ func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchForNewBranc StartedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), } - repo.sessionWorktrees = append(repo.sessionWorktrees, &models.TaskSessionWorktree{ - SessionID: "session-prev", - RepositoryID: "repo-kandev", - BranchSlug: "", - WorktreeID: "wt-stale-empty-branch", + repo.taskEnvironmentRepos[envID] = append(repo.taskEnvironmentRepos[envID], &models.TaskEnvironmentRepo{ + TaskEnvironmentID: envID, + RepositoryID: "repo-kandev", + BranchSlug: "", + WorktreeID: "wt-stale-empty-branch", }) exec := newTestExecutor(t, &mockAgentManager{}, repo) req := &LaunchAgentRequest{ @@ -813,8 +803,7 @@ func TestReuseExistingEnvironment_SingleRepoIgnoresEmptySessionBranchForNewBranc UseWorktree: true, } env := &models.TaskEnvironment{ - ID: envID, - RepositoryID: "repo-kandev", + ID: envID, } exec.reuseExistingEnvironment(context.Background(), req, env) @@ -849,7 +838,9 @@ func TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug(t *test TaskID: taskID, ExecutorType: string(models.ExecutorTypeWorktree), Status: models.TaskEnvironmentStatusReady, - WorktreeID: "wt-main", + Repos: []*models.TaskEnvironmentRepo{ + {TaskEnvironmentID: "env-existing", RepositoryID: "repo-kandev", WorktreeID: "wt-main", BranchSlug: "main"}, + }, } repo.sessions[sourceSessionID] = &models.TaskSession{ ID: sourceSessionID, @@ -867,22 +858,22 @@ func TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug(t *test StartedAt: now, UpdatedAt: now, } - repo.sessionWorktrees = append(repo.sessionWorktrees, - &models.TaskSessionWorktree{ - SessionID: sourceSessionID, - RepositoryID: "repo-kandev", - WorktreeID: "wt-main", - BranchSlug: "main", - WorktreePath: "/tasks/t/kandev", - WorktreeBranch: "feature/t", + repo.taskEnvironmentRepos["env-existing"] = append(repo.taskEnvironmentRepos["env-existing"], + &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-existing", + RepositoryID: "repo-kandev", + WorktreeID: "wt-main", + BranchSlug: "main", + WorktreePath: "/tasks/t/kandev", + WorktreeBranch: "feature/t", }, - &models.TaskSessionWorktree{ - SessionID: sourceSessionID, - RepositoryID: "repo-kandev", - WorktreeID: "wt-branch", - BranchSlug: "branch-5hn", - WorktreePath: "/tasks/t/kandev-branch-5hn", - WorktreeBranch: "branch-5hn", + &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-existing", + RepositoryID: "repo-kandev", + WorktreeID: "wt-branch", + BranchSlug: "branch-5hn", + WorktreePath: "/tasks/t/kandev-branch-5hn", + WorktreeBranch: "branch-5hn", }, ) @@ -1039,7 +1030,7 @@ func TestPersistTaskEnvironmentRepos_RefreshesExistingRows(t *testing.T) { }, } - exec.persistTaskEnvironmentRepos(context.Background(), "env-refresh", []RepoWorktreeResult{ + exec.persistTaskEnvironmentRepos(context.Background(), "env-refresh", []*models.TaskEnvironmentRepo{ { RepositoryID: "repo-kandev", BranchSlug: "main", @@ -1074,7 +1065,7 @@ func TestPersistTaskEnvironmentRepos_MigratesLegacyFlatRowToBranchIdentity(t *te }, } - exec.persistTaskEnvironmentRepos(context.Background(), "env-legacy", []RepoWorktreeResult{ + exec.persistTaskEnvironmentRepos(context.Background(), "env-legacy", []*models.TaskEnvironmentRepo{ { RepositoryID: "repo-kandev", BranchSlug: "release-1.2", diff --git a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go index 77a4fb9141..91370ec626 100644 --- a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go @@ -322,10 +322,10 @@ func TestExecutor_Prompt_PassthroughAttachmentsUseSessionWorktreeFallback(t *tes } seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") workDir := t.TempDir() - repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{{ - SessionID: "sess-1", - RepositoryID: "repo-1", - WorktreePath: workDir, + repo.sessions["sess-1"].Worktrees = []*models.TaskEnvironmentRepo{{ + TaskEnvironmentID: "env-1", + RepositoryID: "repo-1", + WorktreePath: workDir, }} exec := newTestExecutor(t, agentManager, repo) @@ -361,9 +361,9 @@ func TestExecutor_Prompt_PassthroughAttachmentsUseMultiWorktreeRootFallback(t *t if err := os.MkdirAll(workDir, 0o755); err != nil { t.Fatalf("create worktree dir: %v", err) } - repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{ - {SessionID: "sess-1", RepositoryID: "repo-1", WorktreePath: workDir}, - {SessionID: "sess-1", RepositoryID: "repo-2", WorktreePath: filepath.Join(t.TempDir(), "repo-two")}, + repo.sessions["sess-1"].Worktrees = []*models.TaskEnvironmentRepo{ + {TaskEnvironmentID: "env-1", RepositoryID: "repo-1", WorktreePath: workDir}, + {TaskEnvironmentID: "env-1", RepositoryID: "repo-2", WorktreePath: filepath.Join(t.TempDir(), "repo-two")}, } exec := newTestExecutor(t, agentManager, repo) diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 168470a34a..a91d0c477a 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -443,65 +443,6 @@ func buildPrepareResultMetadata(result *lifecycle.EnvPrepareResult) map[string]i return lifecycle.SerializePrepareResult(result) } -func (e *Executor) persistWorktreeAssociation(ctx context.Context, taskID string, session *models.TaskSession, repositoryID string, resp *LaunchAgentResponse) { - // Multi-repo path: persist one TaskSessionWorktree row per per-repo worktree - // returned by the preparer. Each row carries its own RepositoryID so - // downstream lookups can scope by repo. - if len(resp.Worktrees) > 0 { - existingIDs := make(map[string]bool, len(session.Worktrees)) - for _, wt := range session.Worktrees { - existingIDs[wt.WorktreeID] = true - } - for i, w := range resp.Worktrees { - if w.WorktreeID == "" || existingIDs[w.WorktreeID] { - continue - } - row := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: w.WorktreeID, - RepositoryID: w.RepositoryID, - BranchSlug: w.BranchSlug, - Position: i, - WorktreePath: w.WorktreePath, - WorktreeBranch: w.WorktreeBranch, - } - if err := e.repo.CreateTaskSessionWorktree(ctx, row); err != nil { - e.logger.Error("failed to persist session worktree association", - zap.String("task_id", taskID), - zap.String("session_id", session.ID), - zap.String("worktree_id", w.WorktreeID), - zap.String("repository_id", w.RepositoryID), - zap.Error(err)) - } - } - return - } - - if resp.WorktreeID == "" { - return - } - for _, wt := range session.Worktrees { - if wt.WorktreeID == resp.WorktreeID { - return - } - } - sessionWorktree := &models.TaskSessionWorktree{ - SessionID: session.ID, - WorktreeID: resp.WorktreeID, - RepositoryID: repositoryID, - Position: 0, - WorktreePath: resp.WorktreePath, - WorktreeBranch: resp.WorktreeBranch, - } - if err := e.repo.CreateTaskSessionWorktree(ctx, sessionWorktree); err != nil { - e.logger.Error("failed to persist session worktree association", - zap.String("task_id", taskID), - zap.String("session_id", session.ID), - zap.String("worktree_id", resp.WorktreeID), - zap.Error(err)) - } -} - // ResumeSession restarts an existing task session using its stored worktree. // When startAgent is false, only the executor runtime is started (agent process is not launched). func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSession, startAgent bool) (*TaskExecution, error) { @@ -542,7 +483,7 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio return nil } } - req, repositoryID, execCfg, existingEnv, _, err := e.buildResumeRequestAtCredentialBoundary( + req, _, execCfg, existingEnv, _, err := e.buildResumeRequestAtCredentialBoundary( ctx, task, session, startAgent, beforeCredentialLease, ) if err != nil { @@ -627,16 +568,6 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio return nil, err } } - e.persistWorktreeAssociation(ctx, task.ID, session, repositoryID, resp) - // Refresh task_environments after a successful resume so the row reflects - // the new worktree, container, and ready status. Without this, sessions - // that failed on initial launch (empty task_dir_name / worktree_path, - // status=stopped) stay stuck on those stale values even though the resume - // just prepared a fresh worktree — the frontend keeps showing - // "Executor environment is unavailable (stopped)" and disables chat input. - // existingEnv is captured from buildResumeRequest above (resolveResumeTaskEnvironment - // already aborts on real DB errors); when nil, persistTaskEnvironment will - // re-fetch under the per-task lock and create a fresh row if needed. e.persistTaskEnvironment(ctx, task.ID, session, existingEnv, req, resp, execCfg) worktreePath := resp.WorktreePath diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_test.go index a9e09181d7..0194204ca0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_test.go @@ -1304,11 +1304,10 @@ func TestResumeSession_RefreshesStaleEnvironmentRow(t *testing.T) { // LaunchAgent fails before persistTaskEnvironment writes the worktree // fields back. repo.taskEnvironments["env-stale"] = &models.TaskEnvironment{ - ID: "env-stale", - TaskID: taskID, - Status: models.TaskEnvironmentStatusStopped, - WorktreePath: "", - TaskDirName: "", + ID: "env-stale", + TaskID: taskID, + Status: models.TaskEnvironmentStatusStopped, + TaskDirName: "", } const newWorktreePath = "/home/u/.kandev/tasks/resume-after-failure_abc/my-repo" @@ -1334,11 +1333,12 @@ func TestResumeSession_RefreshesStaleEnvironmentRow(t *testing.T) { t.Errorf("env.Status = %q, want %q — without the refresh the frontend keeps showing the executor as unavailable", env.Status, models.TaskEnvironmentStatusReady) } - if env.WorktreePath != newWorktreePath { - t.Errorf("env.WorktreePath = %q, want %q", env.WorktreePath, newWorktreePath) + envRepos := repo.taskEnvironmentRepos["env-stale"] + if len(envRepos) != 1 || envRepos[0].WorktreePath != newWorktreePath { + t.Errorf("env repos = %+v, want one row with path %q", envRepos, newWorktreePath) } - if env.WorktreeID != "wt-new" { - t.Errorf("env.WorktreeID = %q, want %q", env.WorktreeID, "wt-new") + if len(envRepos) != 1 || envRepos[0].WorktreeID != "wt-new" { + t.Errorf("env repos = %+v, want worktree id %q", envRepos, "wt-new") } if env.TaskDirName == "" { t.Error("env.TaskDirName must not be empty after resume; the worktree manager needs it for the on-disk task root") diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 18012598b6..1bf70ced4c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -82,7 +82,7 @@ func TestMockRepositoryGetTaskSessionReturnsDetachedMutableFields(t *testing.T) ExecutorSnapshot: newSnapshot(), EnvironmentSnapshot: newSnapshot(), RepositorySnapshot: newSnapshot(), - Worktrees: []*models.TaskSessionWorktree{ + Worktrees: []*models.TaskEnvironmentRepo{ {ID: "worktree-1", WorktreePath: "/original"}, nil, }, @@ -636,10 +636,14 @@ func TestLaunchPreparedSession_InheritsEnvFromSessionEnvironmentID(t *testing.T) parentEnv := &models.TaskEnvironment{ ID: "env-parent", TaskID: "task-parent", - WorktreeID: "wt-parent", - WorktreePath: "/tmp/parent", WorkspacePath: "/tmp/parent", Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + TaskEnvironmentID: "env-parent", + RepositoryID: "repo-parent", + WorktreeID: "wt-parent", + WorktreePath: "/tmp/parent", + }}, } repo.taskEnvironments[parentEnv.ID] = parentEnv @@ -687,9 +691,9 @@ func TestLaunchPreparedSession_InheritsEnvFromSessionEnvironmentID(t *testing.T) // existingEnv.WorktreeID must flow through. When it didn't, this // assertion is a no-op — the env-id inheritance above is the load- // bearing check for the regression we're guarding against. - if gotUseWorktree && gotWorktreeID != parentEnv.WorktreeID { + if gotUseWorktree && gotWorktreeID != "wt-parent" { t.Errorf("expected reused WorktreeID=%q from inherited env; got %q", - parentEnv.WorktreeID, gotWorktreeID) + "wt-parent", gotWorktreeID) } // No fresh env row should be created — the inherited one was reused. if len(repo.createTaskEnvironmentCalls) != 0 { diff --git a/apps/backend/internal/orchestrator/executor/executor_worktree_resume_test.go b/apps/backend/internal/orchestrator/executor/executor_worktree_resume_test.go index 8eb8f95eae..1b033d12ea 100644 --- a/apps/backend/internal/orchestrator/executor/executor_worktree_resume_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_worktree_resume_test.go @@ -75,8 +75,9 @@ func TestLaunchPreparedSession_SingleRepoWorktree_PersistsSubdirAsWorkspacePath( for _, e := range repo.taskEnvironments { env = e } - if env.WorktreePath != subdir { - t.Errorf("worktree_path = %q, want %q", env.WorktreePath, subdir) + envRepos := env.Repos + if len(envRepos) != 1 || envRepos[0].WorktreePath != subdir { + t.Errorf("env repo worktree_path = %+v, want %q", envRepos, subdir) } if env.WorkspacePath != subdir { t.Errorf("workspace_path = %q, want %q (== worktree_path so process cwd matches between hot and cold start)", diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index d44be00c13..35aea70b33 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -203,8 +203,7 @@ type repoStore interface { UpdateTaskSession(ctx context.Context, session *models.TaskSession) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) - CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error - ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) ListSessionsWithBranches(ctx context.Context) ([]models.SessionBranchInfo, error) GetRepository(ctx context.Context, id string) (*models.Repository, error) UpdateRepository(ctx context.Context, repository *models.Repository) error diff --git a/apps/backend/internal/orchestrator/session_delete_contract_test.go b/apps/backend/internal/orchestrator/session_delete_contract_test.go new file mode 100644 index 0000000000..cf0908cf28 --- /dev/null +++ b/apps/backend/internal/orchestrator/session_delete_contract_test.go @@ -0,0 +1,74 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" +) + +func TestDeleteSession_PreservesTaskWorkspaceAndNeverEnqueuesCleanup(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task-session-only", "session-only", models.TaskSessionStateCompleted) + now := time.Now().UTC() + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-session-only", WorkspaceID: "ws1", Name: "repo", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-session-only", TaskID: "task-session-only", ExecutorType: "worktree", + WorkspacePath: "/tmp/ws-session-only", Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-session-only", RepositoryID: "repo-session-only", + WorktreeID: "wt-session-only", WorktreePath: "/tmp/ws-session-only/repo", + WorktreeBranch: "feature/x", Status: "active", + }}, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-only") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + session.TaskEnvironmentID = "env-session-only" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session: %v", err) + } + + manager := &mockAgentManager{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), manager) + svc.executor = executor.NewExecutor(manager, repo, testLogger(), executor.ExecutorConfig{}) + + if err := svc.DeleteSession(ctx, "session-only"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + + // The session row is gone. + if _, err := repo.GetTaskSession(ctx, "session-only"); err == nil { + t.Fatal("expected session to be gone after deletion") + } + + // The task-owned environment and its repository worktree row survive. + env, err := repo.GetTaskEnvironment(ctx, "env-session-only") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-session-only" || env.Repos[0].Status != "active" { + t.Fatalf("task-owned worktree row lost: %+v", env.Repos) + } + + // No cleanup job of any kind was reserved for the task. + var jobCount int + if err := repo.DB().QueryRow( + `SELECT COUNT(*) FROM task_resource_cleanup_jobs WHERE task_id = 'task-session-only'`, + ).Scan(&jobCount); err != nil { + t.Fatalf("count cleanup jobs: %v", err) + } + if jobCount != 0 { + t.Fatalf("cleanup jobs = %d, want 0 — session deletion must not reserve cleanup", jobCount) + } +} diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index dfa7b2a6da..f55da42b4d 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -451,14 +451,28 @@ func TestLaunchRestoreWorkspace_IncludesWorktreeInfo(t *testing.T) { seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed) - // Add worktree to the session - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "session1", - WorktreeID: "wid1", - RepositoryID: "repo1", - WorktreePath: "/tmp/worktrees/session1", - WorktreeBranch: "feature/test", + // Add worktree to the session's environment + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "task1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to environment: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", }); err != nil { t.Fatalf("failed to create worktree: %v", err) } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index f80e7abaef..eaacbce532 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -2439,11 +2439,14 @@ func (s *Service) populateEnvironmentWorkspaceInfo(ctx context.Context, session if session.TaskEnvironmentID != "" && env.ID != session.TaskEnvironmentID { return } - if resp.WorktreePath == nil && env.WorktreePath != "" { - resp.WorktreePath = &env.WorktreePath + if len(env.Repos) == 0 { + return } - if resp.WorktreeBranch == nil && env.WorktreeBranch != "" { - resp.WorktreeBranch = &env.WorktreeBranch + if resp.WorktreePath == nil && env.Repos[0].WorktreePath != "" { + resp.WorktreePath = &env.Repos[0].WorktreePath + } + if resp.WorktreeBranch == nil && env.Repos[0].WorktreeBranch != "" { + resp.WorktreeBranch = &env.Repos[0].WorktreeBranch } } diff --git a/apps/backend/internal/orchestrator/task_operations_resume_test.go b/apps/backend/internal/orchestrator/task_operations_resume_test.go index 208c5a2c23..b11bef40b2 100644 --- a/apps/backend/internal/orchestrator/task_operations_resume_test.go +++ b/apps/backend/internal/orchestrator/task_operations_resume_test.go @@ -179,14 +179,20 @@ func TestGetTaskSessionStatus_FailedSessionWithoutResumableFlagFallsBack(t *test // Seed a worktree so canRestoreWorkspace returns true. now := time.Now().UTC() - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "session1", - WorktreeID: "wid1", - RepositoryID: "repo1", - WorktreePath: "/tmp/worktrees/session1", - WorktreeBranch: "feature/test", - CreatedAt: now, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "task1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", + CreatedAt: now, }); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -230,14 +236,20 @@ func TestGetTaskSessionStatus_CancelledSessionStaysWorkspaceRestore(t *testing.T seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCancelled) now := time.Now().UTC() - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "session1", - WorktreeID: "wid1", - RepositoryID: "repo1", - WorktreePath: "/tmp/worktrees/session1", - WorktreeBranch: "feature/test", - CreatedAt: now, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "task1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", + CreatedAt: now, }); err != nil { t.Fatalf("failed to create worktree: %v", err) } diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index a8564b6c17..dc9825dfc6 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -5814,15 +5814,16 @@ func TestGetTaskSessionStatus_UsesTaskEnvironmentBranchForDocker(t *testing.T) { now := time.Now().UTC() if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env1", - TaskID: "task1", - ExecutorType: string(models.ExecutorTypeLocalDocker), - WorktreePath: "/workspace", - WorktreeBranch: "feature/test-task-abc", - WorkspacePath: "/workspace", - Status: models.TaskEnvironmentStatusReady, - CreatedAt: now, - UpdatedAt: now, + ID: "env1", + TaskID: "task1", + ExecutorType: string(models.ExecutorTypeLocalDocker), + WorkspacePath: "/workspace", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + RepositoryID: "repo1", WorktreePath: "/workspace", WorktreeBranch: "feature/test-task-abc", + }}, + CreatedAt: now, + UpdatedAt: now, }); err != nil { t.Fatalf("failed to create task environment: %v", err) } @@ -6716,14 +6717,20 @@ func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithWorktree(t *test // Add worktree to session now := time.Now().UTC() - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "session1", - WorktreeID: "wid1", - RepositoryID: "repo1", - WorktreePath: "/tmp/worktrees/session1", - WorktreeBranch: "feature/test", - CreatedAt: now, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "task1", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env1", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session1", + WorktreeBranch: "feature/test", + CreatedAt: now, }); err != nil { t.Fatalf("failed to create worktree: %v", err) } diff --git a/apps/backend/internal/orchestrator/task_title_branch_runtime.go b/apps/backend/internal/orchestrator/task_title_branch_runtime.go index 9740880774..3e74d62e05 100644 --- a/apps/backend/internal/orchestrator/task_title_branch_runtime.go +++ b/apps/backend/internal/orchestrator/task_title_branch_runtime.go @@ -32,7 +32,7 @@ type titleBranchPrimaryRuntime interface { 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) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, 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 @@ -47,13 +47,14 @@ type titleBranchWorktreeSnapshotStore interface { } type titleBranchWorktreeLister interface { - ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) } type titleBranchBinding struct { + sessionID string taskRepository *models.TaskRepository repository *models.Repository - worktree *models.TaskSessionWorktree + worktree *models.TaskEnvironmentRepo environmentRepo *models.TaskEnvironmentRepo plan worktree.BranchIdentityPlan } @@ -119,14 +120,14 @@ func (s *Service) RenameGeneratedBranchesForTaskTitle( } executorType := s.titleBranchExecutorType(ctx, session, env) - bindings, err := s.buildTitleBranchBindings(ctx, store, taskRepositories, worktrees, envRepos) + bindings, err := s.buildTitleBranchBindings(ctx, store, sessionID, 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 { +func missingTitleBranchRepositoryResult(worktrees []*models.TaskEnvironmentRepo) TitleBranchRenameResult { var result TitleBranchRenameResult for _, worktree := range worktrees { if worktree == nil { @@ -148,7 +149,7 @@ func (s *Service) renameTitleBranches( title string, executorType models.ExecutorType, env *models.TaskEnvironment, - worktrees []*models.TaskSessionWorktree, + worktrees []*models.TaskEnvironmentRepo, bindings []titleBranchBinding, ) TitleBranchRenameResult { var result TitleBranchRenameResult @@ -211,8 +212,9 @@ func (s *Service) titleBranchExecutorType(ctx context.Context, session *models.T func (s *Service) buildTitleBranchBindings( ctx context.Context, store titleBranchStore, + sessionID string, taskRepositories []*models.TaskRepository, - worktrees []*models.TaskSessionWorktree, + worktrees []*models.TaskEnvironmentRepo, envRepos map[string][]*models.TaskEnvironmentRepo, ) ([]titleBranchBinding, error) { inputs := make([]worktree.BranchIdentityInput, 0, len(taskRepositories)) @@ -229,6 +231,7 @@ func (s *Service) buildTitleBranchBindings( return nil, fmt.Errorf("repository %q is unavailable", taskRepository.RepositoryID) } bindings = append(bindings, titleBranchBinding{ + sessionID: sessionID, taskRepository: taskRepository, repository: repository, }) @@ -251,8 +254,8 @@ func (s *Service) buildTitleBranchBindings( return bindings, nil } -func titleBranchWorktree(worktrees []*models.TaskSessionWorktree, repositoryID, branchSlug string) *models.TaskSessionWorktree { - var fallback *models.TaskSessionWorktree +func titleBranchWorktree(worktrees []*models.TaskEnvironmentRepo, repositoryID, branchSlug string) *models.TaskEnvironmentRepo { + var fallback *models.TaskEnvironmentRepo for _, sessionWorktree := range worktrees { if sessionWorktree == nil || sessionWorktree.RepositoryID != repositoryID { continue @@ -380,7 +383,7 @@ func (s *Service) performTitleBranchRename( return newName, nil } repoPath := titleBranchAgentctlRepoPath(env, binding, multiRepo) - operation, err := s.renameTitleBranchRuntime(ctx, binding.worktree.SessionID, newName, repoPath, primary) + operation, err := s.renameTitleBranchRuntime(ctx, binding.sessionID, newName, repoPath, primary) if err != nil { return "", err } @@ -417,9 +420,6 @@ func titleBranchCurrentBranch(binding titleBranchBinding, env *models.TaskEnviro if binding.environmentRepo != nil && binding.environmentRepo.WorktreeBranch != "" { return binding.environmentRepo.WorktreeBranch } - if env != nil && env.WorktreeBranch != "" { - return env.WorktreeBranch - } return "" } @@ -464,39 +464,7 @@ func (s *Service) persistTitleBranchSnapshots( 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") + return s.persistTitleBranchEnvironmentRepoSnapshot(ctx, binding, branch) } func (s *Service) persistTitleBranchEnvironmentRepoSnapshot(ctx context.Context, binding titleBranchBinding, branch string) error { @@ -514,42 +482,15 @@ func (s *Service) persistTitleBranchEnvironmentRepoSnapshot(ctx context.Context, 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 { + if binding.worktree == 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 == "" + if binding.plan.PathSlug == "" && binding.taskRepository != nil { + return binding.worktree.RepositoryID == binding.taskRepository.RepositoryID } return binding.taskRepository != nil && binding.taskRepository.Position == 0 } @@ -558,11 +499,6 @@ func titleBranchGeneratedSnapshot(binding titleBranchBinding, env *models.TaskEn 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 } diff --git a/apps/backend/internal/orchestrator/task_title_branch_service_test.go b/apps/backend/internal/orchestrator/task_title_branch_service_test.go index 5330a807d4..554ac55f57 100644 --- a/apps/backend/internal/orchestrator/task_title_branch_service_test.go +++ b/apps/backend/internal/orchestrator/task_title_branch_service_test.go @@ -34,6 +34,10 @@ func (s *failingTitleBranchSnapshotStore) UpdateTaskSessionWorktreeBranchByWorkt return s.err } +func (s *failingTitleBranchSnapshotStore) UpdateTaskEnvironmentRepo(context.Context, *models.TaskEnvironmentRepo) 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 { @@ -62,7 +66,7 @@ func TestRenameGeneratedBranchesForTaskTitleUsesFinalTitleAndPersistsSnapshots(t BaseBranch: "main", Position: 0, })) require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-title-branch", TaskID: "task-title-branch", RepositoryID: "repo-title-branch", + ID: "env-title-branch", TaskID: "task-title-branch", ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, Status: models.TaskEnvironmentStatusReady, Repos: []*models.TaskEnvironmentRepo{{ @@ -71,11 +75,6 @@ func TestRenameGeneratedBranchesForTaskTitleUsesFinalTitleAndPersistsSnapshots(t 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" @@ -118,7 +117,7 @@ func TestRenameGeneratedBranchesForTaskTitleUsesStableWorktreeSuffix(t *testing. 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", + ID: "env-title-stable-suffix", TaskID: "task-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", @@ -126,11 +125,6 @@ func TestRenameGeneratedBranchesForTaskTitleUsesStableWorktreeSuffix(t *testing. 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" @@ -161,9 +155,10 @@ func TestRenameGeneratedBranchesForTaskTitlePreservesManuallySelectedBranch(t *t &models.TaskEnvironment{Repos: []*models.TaskEnvironmentRepo{{WorktreeBranch: "feature/provisional"}}}, false, titleBranchBinding{ + sessionID: "session-manual-branch", 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"}, + worktree: &models.TaskEnvironmentRepo{RepositoryID: "repo-manual-branch", WorktreeBranch: "feature/user-selected"}, environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-manual-branch", WorktreeBranch: "feature/provisional"}, }, &result, @@ -188,7 +183,7 @@ func TestRenameGeneratedBranchesForTaskTitlePreservesRemoteCheckout(t *testing.T 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", + ID: "env-remote-branch", TaskID: "task-remote-branch", ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, Status: models.TaskEnvironmentStatusReady, Repos: []*models.TaskEnvironmentRepo{{ @@ -197,11 +192,6 @@ func TestRenameGeneratedBranchesForTaskTitlePreservesRemoteCheckout(t *testing.T 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" @@ -245,12 +235,6 @@ func TestRenameGeneratedBranchesForTaskTitleScopesMixedRepositories(t *testing.T {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" @@ -289,7 +273,7 @@ func TestRenameGeneratedBranchesForTaskTitleScopesOwnerSession(t *testing.T) { BaseBranch: "main", Position: 0, })) require.NoError(t, repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-owner-scope", TaskID: "task-owner-scope", RepositoryID: "repo-owner-scope", + ID: "env-owner-scope", TaskID: "task-owner-scope", ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: taskRoot, Status: models.TaskEnvironmentStatusReady, Repos: []*models.TaskEnvironmentRepo{{ @@ -302,12 +286,10 @@ func TestRenameGeneratedBranchesForTaskTitleScopesOwnerSession(t *testing.T) { 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)) - } + nonOwnerSession, err := repo.GetTaskSession(ctx, "session-non-owner-scope") + require.NoError(t, err) + nonOwnerSession.TaskEnvironmentID = "env-owner-scope" + require.NoError(t, repo.UpdateTaskSession(ctx, nonOwnerSession)) service := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) runtime := &titleBranchRuntimeStub{} @@ -317,6 +299,8 @@ func TestRenameGeneratedBranchesForTaskTitleScopesOwnerSession(t *testing.T) { require.Equal(t, TitleBranchStatusRenamed, result.Status) require.Equal(t, []titleBranchRuntimeCall{{sessionID: "session-owner-scope", newName: "feature/final-title", repo: ""}}, runtime.calls) + // Both sessions share the task environment, so both observe the renamed + // branch — the rename is scoped to the environment, not per session. ownerWorktrees, err := repo.ListTaskSessionWorktrees(ctx, "session-owner-scope") require.NoError(t, err) require.Len(t, ownerWorktrees, 1) @@ -324,7 +308,7 @@ func TestRenameGeneratedBranchesForTaskTitleScopesOwnerSession(t *testing.T) { 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) + require.Equal(t, "feature/final-title", nonOwnerWorktrees[0].WorktreeBranch) } func TestRenameGeneratedBranchesForTaskTitlePreservesLocalExecutor(t *testing.T) { @@ -341,7 +325,7 @@ func TestRenameGeneratedBranchesForTaskTitlePreservesLocalExecutor(t *testing.T) 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"}, + worktree: &models.TaskEnvironmentRepo{RepositoryID: "repo-local", WorktreeBranch: "user-branch"}, }, &result, ) @@ -364,7 +348,7 @@ func TestRenameGeneratedBranchesForTaskTitleReportsGitRenameFailure(t *testing.T 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"}, + worktree: &models.TaskEnvironmentRepo{RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, }, &result, @@ -397,7 +381,7 @@ func TestRenameGeneratedBranchesForTaskTitleReportsSnapshotFailure(t *testing.T) 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"}, + worktree: &models.TaskEnvironmentRepo{WorktreeID: "worktree-snapshot-failure", RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, environmentRepo: &models.TaskEnvironmentRepo{RepositoryID: "repo-snapshot-failure", WorktreeBranch: "feature/provisional"}, }, &result, diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index 250d7c0080..f3cc780f6f 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -105,7 +105,7 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { ID: "session-1", TaskID: "task-1", WorkspacePath: "/task-root", - Worktrees: []*models.TaskSessionWorktree{ + Worktrees: []*models.TaskEnvironmentRepo{ {ID: "assoc-1", WorktreeID: "wt-1", RepositoryID: "repo-a", WorktreePath: "/x/a"}, {ID: "assoc-2", WorktreeID: "wt-2", RepositoryID: "repo-b", WorktreePath: "/x/b"}, }, @@ -126,8 +126,11 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { if len(summary.Worktrees) != 2 { t.Fatalf("FromTaskSessionSummary Worktrees len = %d, want 2", len(summary.Worktrees)) } - if summary.Worktrees[1].WorktreeID != "wt-2" { - t.Fatalf("second worktree id = %q, want wt-2", summary.Worktrees[1].WorktreeID) + if summary.Worktrees[1]["worktree_id"] != "wt-2" { + t.Fatalf("second worktree id = %v, want wt-2", summary.Worktrees[1]["worktree_id"]) + } + if summary.Worktrees[0]["session_id"] != "session-1" { + t.Fatalf("worktree session_id = %v, want session-1 (stable wire shape)", summary.Worktrees[0]["session_id"]) } if summary.WorkspacePath != "/task-root" { t.Fatalf("summary WorkspacePath = %q, want /task-root", summary.WorkspacePath) diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index d34316f4d4..bd751489d3 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -258,19 +258,20 @@ type TaskSessionDTO struct { // WorktreePath remains the flattened primary repository path. WorkspacePath string `json:"workspace_path,omitempty"` // Worktrees lists all session worktrees (one per repo on multi-repo tasks); - // the flattened Worktree* fields above carry only the first for - // backward compatibility. - Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"` - State models.TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` - ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` - EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` - RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + // the flattened Worktree* fields above carry only the first for backward + // compatibility. Entries use the stable session-worktree wire shape + // (session_id + worktree identity) shared with TaskSession.ToAPI. + Worktrees []map[string]interface{} `json:"worktrees,omitempty"` + State models.TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` + ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` + EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` + RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` // Workflow fields IsPrimary bool `json:"is_primary"` IsPassthrough bool `json:"is_passthrough"` @@ -330,19 +331,19 @@ type TaskSessionSummaryDTO struct { // WorktreePath remains the flattened primary repository path. WorkspacePath string `json:"workspace_path,omitempty"` // Worktrees lists all session worktrees (one per repo on multi-repo tasks); - // the flattened Worktree* fields above carry only the first for - // backward compatibility. - Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"` - State models.TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - IsPrimary bool `json:"is_primary"` - IsPassthrough bool `json:"is_passthrough"` - ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` - TaskEnvironmentID string `json:"task_environment_id,omitempty"` + // the flattened Worktree* fields above carry only the first for backward + // compatibility. Entries use the stable session-worktree wire shape. + Worktrees []map[string]interface{} `json:"worktrees,omitempty"` + State models.TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + IsPrimary bool `json:"is_primary"` + IsPassthrough bool `json:"is_passthrough"` + ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` + TaskEnvironmentID string `json:"task_environment_id,omitempty"` // ForegroundActivity mirrors the in-memory fine-grained busy substate // (ADR-0049); see TaskSessionDTO. ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"` @@ -802,11 +803,11 @@ func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO { TaskEnvironmentID: session.TaskEnvironmentID, LastReadMessageID: session.LastReadMessageID, } - if len(session.Worktrees) > 0 { + if worktrees := session.WorktreesAPI(); len(worktrees) > 0 { result.WorktreeID = session.Worktrees[0].WorktreeID result.WorktreePath = session.Worktrees[0].WorktreePath result.WorktreeBranch = session.Worktrees[0].WorktreeBranch - result.Worktrees = session.Worktrees + result.Worktrees = worktrees } return result } @@ -843,11 +844,11 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { TaskEnvironmentID: session.TaskEnvironmentID, LastReadMessageID: session.LastReadMessageID, } - if len(session.Worktrees) > 0 { + if worktrees := session.WorktreesAPI(); len(worktrees) > 0 { result.WorktreeID = session.Worktrees[0].WorktreeID result.WorktreePath = session.Worktrees[0].WorktreePath result.WorktreeBranch = session.Worktrees[0].WorktreeBranch - result.Worktrees = session.Worktrees + result.Worktrees = worktrees } return result } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 4fc7e5764a..bf566cc1ca 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -349,14 +349,11 @@ func (m *mockRepository) DeleteEphemeralTasksByAgentProfile(ctx context.Context, func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error { return nil } -func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { - return nil -} -func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { +func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) { return nil, nil } -func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskSessionWorktree, error) { - return make(map[string][]*models.TaskSessionWorktree), nil +func (m *mockRepository) ListWorktreesBySessionIDs(_ context.Context, _ []string) (map[string][]*models.TaskEnvironmentRepo, error) { + return make(map[string][]*models.TaskEnvironmentRepo), nil } func (m *mockRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { return nil diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 785d7bae2a..0be55af352 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -996,21 +996,6 @@ const ( TaskSessionStateCancelled TaskSessionState = "CANCELLED" ) -// TaskSessionWorktree represents the association between a task session and a worktree -type TaskSessionWorktree struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - WorktreeID string `json:"worktree_id"` - RepositoryID string `json:"repository_id"` - BranchSlug string `json:"branch_slug,omitempty"` - Position int `json:"position"` - CreatedAt time.Time `json:"created_at"` - - // Worktree details stored on this association - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` -} - // SessionBranchInfo is a lightweight projection of a session with its worktree branch. // Used by the PR watch reconciler to find sessions that may need PR watches. type SessionBranchInfo struct { @@ -1033,11 +1018,11 @@ type TaskSession struct { ExecutorID string `json:"executor_id"` ExecutorProfileID string `json:"executor_profile_id"` EnvironmentID string `json:"environment_id"` - RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) - BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) - BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) - WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder - Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees + RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility) + BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility) + BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff) + WorkspacePath string `json:"workspace_path"` // Effective task workspace root; legacy repo-less sessions may use the picked host folder + Worktrees []*TaskEnvironmentRepo `json:"-"` // Environment repository rows for this session's workspace AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` @@ -1080,15 +1065,12 @@ func (s *TaskSession) ToAPI() map[string]interface{} { "repository_id": s.RepositoryID, "base_branch": s.BaseBranch, "base_commit_sha": s.BaseCommitSHA, - "worktrees": s.Worktrees, "state": string(s.State), "started_at": s.StartedAt, "updated_at": s.UpdatedAt, } - // For backward compatibility, populate worktree_path and worktree_branch from first worktree - if len(s.Worktrees) > 0 { - result["worktree_path"] = s.Worktrees[0].WorktreePath - result["worktree_branch"] = s.Worktrees[0].WorktreeBranch + if worktrees := s.WorktreesAPI(); len(worktrees) > 0 { + result["worktrees"] = worktrees } if s.Name != "" { result["name"] = s.Name @@ -1131,6 +1113,60 @@ func (s *TaskSession) ToAPI() map[string]interface{} { return result } +// WorktreesAPI maps the session's environment repository rows to the legacy +// session-worktree API shape. The wire contract is stable: the frontend reads +// id, worktree_id, repository_id, branch_slug, position, worktree_path, and +// worktree_branch from each entry, and worktree_path/worktree_branch are +// mirrored onto the session for backward compatibility. +func (s *TaskSession) WorktreesAPI() []map[string]interface{} { + if len(s.Worktrees) == 0 { + return nil + } + out := make([]map[string]interface{}, 0, len(s.Worktrees)) + for _, repo := range s.Worktrees { + if repo == nil { + continue + } + entry := map[string]interface{}{ + "id": repo.ID, + "session_id": s.ID, + "worktree_id": repo.WorktreeID, + "repository_id": repo.RepositoryID, + "position": repo.Position, + "created_at": repo.CreatedAt, + } + if repo.BranchSlug != "" { + entry["branch_slug"] = repo.BranchSlug + } + if repo.WorktreePath != "" { + entry["worktree_path"] = repo.WorktreePath + } + if repo.WorktreeBranch != "" { + entry["worktree_branch"] = repo.WorktreeBranch + } + out = append(out, entry) + } + return out +} + +// WorktreePath returns the workspace path of the session's first repository +// row, used for backward-compatible projections. +func (s *TaskSession) WorktreePath() string { + if len(s.Worktrees) > 0 && s.Worktrees[0] != nil { + return s.Worktrees[0].WorktreePath + } + return "" +} + +// WorktreeBranch returns the branch of the session's first repository row, +// used for backward-compatible projections. +func (s *TaskSession) WorktreeBranch() string { + if len(s.Worktrees) > 0 && s.Worktrees[0] != nil { + return s.Worktrees[0].WorktreeBranch + } + return "" +} + // Repository represents a workspace repository type Repository struct { ID string `json:"id"` @@ -1374,7 +1410,6 @@ const ( type TaskEnvironment struct { ID string `json:"id"` TaskID string `json:"task_id"` - RepositoryID string `json:"repository_id"` // Deprecated: use Repos. Kept for dual-write/backwards compat. ExecutorType string `json:"executor_type"` ExecutorID string `json:"executor_id"` ExecutorProfileID string `json:"executor_profile_id"` @@ -1384,16 +1419,12 @@ type TaskEnvironment struct { ControlPort int `json:"control_port"` // agentctl control port Status TaskEnvironmentStatus `json:"status"` - // Type-specific fields. The single worktree fields below are legacy: with - // multi-repo tasks, the per-repo worktrees live on Repos. WorkspacePath - // continues to point at the agent workspace root (the task root when + // WorkspacePath points at the agent workspace root (the task root when // TaskDirName is set, otherwise the single repo's worktree path). - WorktreeID string `json:"worktree_id,omitempty"` // Deprecated: use Repos[i].WorktreeID - WorktreePath string `json:"worktree_path,omitempty"` // Deprecated: use Repos[i].WorktreePath - WorktreeBranch string `json:"worktree_branch,omitempty"` // Deprecated: use Repos[i].WorktreeBranch - WorkspacePath string `json:"workspace_path,omitempty"` - ContainerID string `json:"container_id,omitempty"` - SandboxID string `json:"sandbox_id,omitempty"` + // Physical worktree identity lives on Repos, never on the environment row. + WorkspacePath string `json:"workspace_path,omitempty"` + ContainerID string `json:"container_id,omitempty"` + SandboxID string `json:"sandbox_id,omitempty"` // TaskDirName is the semantic directory name for the task (e.g. "fix-bug_ab12"). // Set when the task uses the multi-repo task-directory layout @@ -1401,8 +1432,8 @@ type TaskEnvironment struct { TaskDirName string `json:"task_dir_name,omitempty"` // Repos contains one entry per repository associated with this environment. - // Populated by repository getters. Empty for environments created before the - // multi-repo backfill ran with no legacy repository_id. + // Each row is the single source of physical-worktree truth for that + // repository slot. Populated by repository getters. Repos []*TaskEnvironmentRepo `json:"repos,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -1421,19 +1452,24 @@ func (te *TaskEnvironment) RepoFor(repositoryID string) *TaskEnvironmentRepo { // TaskEnvironmentRepo represents the per-repository state of a task environment. // One row per repository associated with the task: each carries its own worktree -// reference and any per-repo preparation error. +// reference and any per-repo preparation error. The row is the single source of +// physical-worktree truth — identity, path, branch, status, and lifecycle +// timestamps. type TaskEnvironmentRepo struct { - ID string `json:"id"` - TaskEnvironmentID string `json:"task_environment_id"` - RepositoryID string `json:"repository_id"` - BranchSlug string `json:"branch_slug,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - Position int `json:"position"` - ErrorMessage string `json:"error_message,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TaskEnvironmentID string `json:"task_environment_id"` + RepositoryID string `json:"repository_id"` + BranchSlug string `json:"branch_slug,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + Position int `json:"position"` + ErrorMessage string `json:"error_message,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // ToAPI converts internal TaskEnvironment to API map. @@ -1441,7 +1477,6 @@ func (te *TaskEnvironment) ToAPI() map[string]interface{} { result := map[string]interface{}{ "id": te.ID, "task_id": te.TaskID, - "repository_id": te.RepositoryID, "executor_type": te.ExecutorType, "executor_id": te.ExecutorID, "executor_profile_id": te.ExecutorProfileID, @@ -1454,15 +1489,6 @@ func (te *TaskEnvironment) ToAPI() map[string]interface{} { if te.ControlPort != 0 { result["control_port"] = te.ControlPort } - if te.WorktreeID != "" { - result["worktree_id"] = te.WorktreeID - } - if te.WorktreePath != "" { - result["worktree_path"] = te.WorktreePath - } - if te.WorktreeBranch != "" { - result["worktree_branch"] = te.WorktreeBranch - } if te.ContainerID != "" { result["container_id"] = te.ContainerID } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index e2e518fee8..a5dff00985 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -260,21 +260,21 @@ type SessionRepository interface { UpdateTaskSessionLastReadMessageID(ctx context.Context, id, messageID string) error } -// SessionWorktreeRepository handles the task session↔worktree association. +// SessionWorktreeRepository exposes session-scoped worktree projections over +// the task environment's repository rows. Sessions reference worktrees only +// through task_sessions.task_environment_id. type SessionWorktreeRepository interface { - CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error UpdateTaskSessionWorktreeBranch(ctx context.Context, sessionID, branch string) error UpdateTaskSessionWorktreeBranchByRepository(ctx context.Context, sessionID, repositoryID, 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 - DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) + ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskEnvironmentRepo, error) } // TaskResourceCleanupRepository persists restart-safe task lifecycle cleanup. type TaskResourceCleanupRepository interface { CreateTaskResourceCleanupJob(ctx context.Context, job *models.TaskResourceCleanupJob) error HasActiveTaskResourceCleanupJob(ctx context.Context, taskID string) (bool, error) + UpdateTaskResourceCleanupSnapshot(ctx context.Context, operationID, snapshot string) error GetTaskResourceCleanupJob(ctx context.Context, id string) (*models.TaskResourceCleanupJob, error) GetTaskResourceCleanupJobByOperationID(ctx context.Context, operationID string) (*models.TaskResourceCleanupJob, error) ListPreparedTaskResourceCleanupJobs(ctx context.Context) ([]*models.TaskResourceCleanupJob, error) diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index f3bdf29ff0..89516bd7d1 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -20,3 +20,9 @@ var ErrRepositoryNotFound = errors.New("repository not found") // ErrTaskEnvironmentNotFound reports that no task environment row matched the supplied id. var ErrTaskEnvironmentNotFound = errors.New("task environment not found") + +// ErrTaskCleanupInProgress reports that a task lifecycle cleanup barrier is +// active, so new sessions or physical worktrees cannot be admitted for the +// task. Creation races resolve by rejecting the late comer; the cleanup +// inventory was captured under the same barrier. +var ErrTaskCleanupInProgress = errors.New("task cleanup in progress") diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 69388e1849..12afeda62a 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -22,6 +22,10 @@ type Repository struct { migrate *db.MigrateLogger queuePurgeMu sync.RWMutex queuePurger func(context.Context, string) + // failCutoverAfter is a test-only failpoint for the worktree ownership + // cutover: when set to a cutover step name, the migration aborts at that + // step so tests can prove rollback restores the pre-upgrade state. + failCutoverAfter string } // SetTaskQueuePurger registers the orchestrator-owned queue cleanup for diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 57fedacc46..ae860cb8e7 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/google/uuid" "go.uber.org/zap" "github.com/kandev/kandev/internal/db/dialect" @@ -70,7 +69,6 @@ func (r *Repository) runMigrations() error { r.migrate.Apply("task_sessions.base_commit_sha", `ALTER TABLE task_sessions ADD COLUMN base_commit_sha TEXT DEFAULT ''`) r.migrate.Apply("workspaces.default_config_agent_profile_id", `ALTER TABLE workspaces ADD COLUMN default_config_agent_profile_id TEXT DEFAULT ''`) r.migrate.Apply("task_sessions.task_environment_id", `ALTER TABLE task_sessions ADD COLUMN task_environment_id TEXT DEFAULT ''`) - r.migrate.Apply("task_session_worktrees.branch_slug", `ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("tasks.parent_id", `ALTER TABLE tasks ADD COLUMN parent_id TEXT DEFAULT ''`) // Remove FK constraint on workflow_id to allow ephemeral tasks without workflows if err := r.migrateTasksRemoveWorkflowFK(); err != nil { @@ -861,145 +859,11 @@ func (r *Repository) migrateSessionsRemoveWorkflowStepID() error { }) } -type backfillRow struct { - taskID, executorID, executorProfileID string - repositoryID, containerID string - startedAt string -} - -// backfillTaskEnvironments creates TaskEnvironment records for historical tasks -// that have sessions but no environment, and links orphaned sessions. -// Idempotent: tasks with existing environments are skipped. -func (r *Repository) backfillTaskEnvironments() error { - if dialect.IsPostgres(r.db.DriverName()) { - return nil - } - - orphaned, err := r.findOrphanedTasks() - if err != nil { - return err - } - if len(orphaned) == 0 { - return nil - } - - tx, err := r.db.Begin() - if err != nil { - return fmt.Errorf("backfill: begin tx: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - for _, row := range orphaned { - if err := r.backfillSingleTask(tx, row); err != nil { - return err - } - } - return tx.Commit() -} - -// findOrphanedTasks returns tasks that have sessions but no task_environments row. -// -// Pre-refactor this also read ts.container_id; that column was dropped from -// task_sessions when executors_running became the source of truth. Historical -// orphaned envs for already-launched sessions get container_id from the -// executors_running row via the LEFT JOIN; sessions without a row have empty -// container_id (they were never launched, so no container to track). -func (r *Repository) findOrphanedTasks() ([]backfillRow, error) { - rows, err := r.db.Query(` - SELECT ts.task_id, - MIN(COALESCE(ts.executor_id, '')), - MIN(COALESCE(ts.executor_profile_id, '')), - MIN(COALESCE(ts.repository_id, '')), - MIN(COALESCE(er.container_id, '')), - MIN(ts.started_at) - FROM task_sessions ts - LEFT JOIN task_environments te ON te.task_id = ts.task_id - LEFT JOIN executors_running er ON er.session_id = ts.id - WHERE te.id IS NULL - GROUP BY ts.task_id - `) - if err != nil { - return nil, fmt.Errorf("backfill: query orphaned tasks: %w", err) - } - defer func() { _ = rows.Close() }() - - var orphaned []backfillRow - for rows.Next() { - var row backfillRow - if err := rows.Scan(&row.taskID, &row.executorID, &row.executorProfileID, - &row.repositoryID, &row.containerID, &row.startedAt); err != nil { - return nil, fmt.Errorf("backfill: scan: %w", err) - } - orphaned = append(orphaned, row) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("backfill: rows: %w", err) - } - return orphaned, nil -} - -// healTaskEnvironmentWorkspacePaths backfills workspace_path on worktree-mode -// envs that have a worktree_path set but an empty workspace_path. Such rows -// trigger ErrSessionWorkspaceNotReady forever in GetOrEnsureExecutionForEnvironment -// and leave shell terminals stuck on "Connecting terminal...". -// -// It also repairs rows where workspace_path was the task-root parent of -// worktree_path — a pre-fix value left by the legacy computeWorkspacePath -// that collapsed single-repo worktree paths via filepath.Dir. After the fix, -// workspace_path must equal worktree_path (the agent process cwd) so ACP -// session/load on cold start hits the same sanitized-cwd jsonl folder the -// agent wrote on hot start. Without this repair, existing single-repo -// Worktree tasks keep failing with -32002 after upgrade. -// -// Idempotent — once workspace_path == worktree_path nothing more is changed. -func (r *Repository) healTaskEnvironmentWorkspacePaths() error { - // substr(...) prefix match is safer than LIKE here: paths may contain - // "_" or "%", both of which are LIKE wildcards in SQLite. - rows, err := r.db.Query(` - SELECT id, worktree_path - FROM task_environments - WHERE executor_type = 'worktree' - AND COALESCE(worktree_path, '') != '' - AND COALESCE(workspace_path, '') != worktree_path - AND ( - COALESCE(workspace_path, '') = '' - OR (length(workspace_path) < length(worktree_path) - AND substr(worktree_path, 1, length(workspace_path)) = workspace_path) - ) - `) - if err != nil { - return fmt.Errorf("heal workspace_path: query: %w", err) - } - type healRow struct{ id, worktreePath string } - var pending []healRow - for rows.Next() { - var hr healRow - if err := rows.Scan(&hr.id, &hr.worktreePath); err != nil { - _ = rows.Close() - return fmt.Errorf("heal workspace_path: scan: %w", err) - } - pending = append(pending, hr) - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return fmt.Errorf("heal workspace_path: rows: %w", err) - } - _ = rows.Close() - if len(pending) == 0 { - return nil - } - - for _, hr := range pending { - if _, err := r.db.Exec( - `UPDATE task_environments SET workspace_path = ?, updated_at = datetime('now') WHERE id = ?`, - hr.worktreePath, hr.id, - ); err != nil { - return fmt.Errorf("heal workspace_path: update %s: %w", hr.id, err) - } - } - return nil -} - +// The legacy startup heals for task environments (backfillTaskEnvironments, +// backfillTaskEnvironmentRepos, healTaskEnvironmentWorkspacePaths) were folded +// into the one-time worktree ownership cutover +// (normalizeTaskWorktreeOwnership in worktree_ownership_migration.go). The +// legacy tables and columns they read no longer exist at startup. // healDuplicateTaskEnvironments collapses rows where a single task has more // than one task_environments row (race in lazy create). Keeps the most recently // updated row and re-points any sessions still referring to the loser. @@ -1119,121 +983,6 @@ func (r *Repository) healSessionTaskEnvironmentIDs() error { return nil } -// backfillSingleTask creates a task_environment and links sessions for one orphaned task. -func (r *Repository) backfillSingleTask(tx *sql.Tx, row backfillRow) error { - envID := uuid.New().String() - - // Look up executor type from executors table. Default to "local_pc" ONLY - // when the executor row genuinely doesn't exist (e.g. legacy session whose - // executor was deleted). Any other scan error — driver failure, schema - // mismatch, type assertion bug — must abort the migration so the operator - // sees the underlying problem instead of every backfilled environment - // silently getting the wrong executor_type. - var executorType string - if err := tx.QueryRow(`SELECT type FROM executors WHERE id = ?`, row.executorID).Scan(&executorType); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("backfill: lookup executor type for task %s: %w", row.taskID, err) - } - executorType = "local_pc" - } - - // Look up worktree info from task_session_worktrees (best effort) - var wtID, wtPath, wtBranch string - _ = tx.QueryRow(` - SELECT w.worktree_id, w.worktree_path, w.worktree_branch - FROM task_session_worktrees w - JOIN task_sessions ts ON ts.id = w.session_id - WHERE ts.task_id = ? - LIMIT 1 - `, row.taskID).Scan(&wtID, &wtPath, &wtBranch) - - // Insert task_environment with status "stopped" (historical, agentctl not running). - // Pre-refactor this also wrote agent_execution_id; that column is gone from - // task_environments (executors_running is the only carrier of execution state now). - if _, err := tx.Exec(` - INSERT INTO task_environments ( - id, task_id, repository_id, executor_type, executor_id, - executor_profile_id, control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, 0, 'stopped', ?, ?, ?, '', ?, '', ?, datetime('now')) - `, envID, row.taskID, row.repositoryID, executorType, row.executorID, - row.executorProfileID, wtID, wtPath, wtBranch, row.containerID, row.startedAt); err != nil { - return fmt.Errorf("backfill: insert env for task %s: %w", row.taskID, err) - } - - // Link all sessions for this task that lack task_environment_id - if _, err := tx.Exec(` - UPDATE task_sessions - SET task_environment_id = ? - WHERE task_id = ? AND (task_environment_id = '' OR task_environment_id IS NULL) - `, envID, row.taskID); err != nil { - return fmt.Errorf("backfill: link sessions for task %s: %w", row.taskID, err) - } - return nil -} - -// backfillTaskEnvironmentRepos populates task_environment_repos from the legacy -// single-repo fields on task_environments. One row per environment that has a -// non-empty repository_id and no existing task_environment_repos row. -// Idempotent. -func (r *Repository) backfillTaskEnvironmentRepos() error { - rows, err := r.db.Query(` - SELECT te.id, - te.repository_id, - COALESCE(te.worktree_id, ''), - COALESCE(te.worktree_path, ''), - COALESCE(te.worktree_branch, ''), - te.created_at - FROM task_environments te - LEFT JOIN task_environment_repos ter ON ter.task_environment_id = te.id - WHERE te.repository_id != '' AND ter.id IS NULL - `) - if err != nil { - return fmt.Errorf("backfill repos: query: %w", err) - } - defer func() { _ = rows.Close() }() - - type envRepoRow struct { - envID, repoID, wtID, wtPath, wtBranch, createdAt string - } - var pending []envRepoRow - for rows.Next() { - var row envRepoRow - if err := rows.Scan(&row.envID, &row.repoID, &row.wtID, &row.wtPath, &row.wtBranch, &row.createdAt); err != nil { - return fmt.Errorf("backfill repos: scan: %w", err) - } - pending = append(pending, row) - } - if err := rows.Err(); err != nil { - return fmt.Errorf("backfill repos: rows: %w", err) - } - if len(pending) == 0 { - return nil - } - - tx, err := r.db.Begin() - if err != nil { - return fmt.Errorf("backfill repos: begin tx: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - for _, row := range pending { - if _, err := tx.Exec(` - INSERT INTO task_environment_repos ( - id, task_environment_id, repository_id, branch_slug, - worktree_id, worktree_path, worktree_branch, - position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, '', ?, ?, ?, 0, '', ?, ?) - `, uuid.New().String(), row.envID, row.repoID, - row.wtID, row.wtPath, row.wtBranch, - row.createdAt, row.createdAt); err != nil { - return fmt.Errorf("backfill repos: insert env %s: %w", row.envID, err) - } - } - return tx.Commit() -} - // Startup healing of orphaned workflow_step_id values was removed: a raw SQL // UPDATE reassigning tasks to a workflow's start step bypasses every // domain-level invariant the task service enforces on a move (WIP limits, diff --git a/apps/backend/internal/task/repository/sqlite/base_queries.go b/apps/backend/internal/task/repository/sqlite/base_queries.go index 206fe282f3..c35b36d326 100644 --- a/apps/backend/internal/task/repository/sqlite/base_queries.go +++ b/apps/backend/internal/task/repository/sqlite/base_queries.go @@ -13,6 +13,14 @@ const sqlLimitClause = " LIMIT ?" // lower bound so batched IN-clause queries stay portable across builds. const sqliteMaxHostParams = 500 +// worktree repo status values on task_environment_repos. Mirrors the worktree +// package's StatusActive/StatusMerged/StatusDeleted lifecycle. +const ( + worktreeRepoStatusActive = "active" + worktreeRepoStatusMerged = "merged" + worktreeRepoStatusDeleted = "deleted" +) + // buildInPlaceholders returns a comma-separated "?,?,?" placeholder string and // the matching args slice for an IN (...) clause over the given string IDs. // Caller is responsible for splitting overly large input via chunkIDs. diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index e15db15f9a..fe7dc1fb4b 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -31,9 +31,7 @@ func (r *Repository) initSchema() error { r.ensureDefaultExecutorsAndEnvironments, r.runMigrations, r.hideBuiltinWorkflows, - r.backfillTaskEnvironments, - r.backfillTaskEnvironmentRepos, - r.healTaskEnvironmentWorkspacePaths, + r.normalizeTaskWorktreeOwnership, r.healDuplicateTaskEnvironments, r.ensureTaskEnvironmentTaskUniqueIndex, r.healSessionTaskEnvironmentIDs, @@ -666,9 +664,12 @@ func (r *Repository) initMessageTurnSchema() error { return err } -// sessionWorktreeSchemaDDL groups task_sessions, task_environments, -// task_environment_repos, and task_session_worktrees DDL so the owning -// function stays within the funlen limit. +// sessionWorktreeSchemaDDL groups task_sessions, task_environments, and +// task_environment_repos DDL so the owning function stays within the funlen +// limit. task_environment_repos is the only physical-worktree record; the +// legacy task_session_worktrees table and the flat worktree columns on +// task_environments were removed by the one-time cutover migration +// (normalizeTaskWorktreeOwnership). const sessionWorktreeSchemaDDL = ` CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, @@ -710,16 +711,11 @@ const sessionWorktreeSchemaDDL = ` CREATE TABLE IF NOT EXISTS task_environments ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, - repository_id TEXT DEFAULT '', executor_type TEXT NOT NULL DEFAULT '', executor_id TEXT DEFAULT '', executor_profile_id TEXT DEFAULT '', - agent_execution_id TEXT DEFAULT '', control_port INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'creating', - worktree_id TEXT DEFAULT '', - worktree_path TEXT DEFAULT '', - worktree_branch TEXT DEFAULT '', workspace_path TEXT DEFAULT '', container_id TEXT DEFAULT '', sandbox_id TEXT DEFAULT '', @@ -742,37 +738,17 @@ const sessionWorktreeSchemaDDL = ` worktree_branch TEXT DEFAULT '', position INTEGER DEFAULT 0, error_message TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP, FOREIGN KEY (task_environment_id) REFERENCES task_environments(id) ON DELETE CASCADE, UNIQUE(task_environment_id, repository_id, branch_slug) ); CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id); CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id); - - CREATE TABLE IF NOT EXISTS task_session_worktrees ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - worktree_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - branch_slug TEXT NOT NULL DEFAULT '', - position INTEGER DEFAULT 0, - worktree_path TEXT DEFAULT '', - worktree_branch TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - merged_at TIMESTAMP, - deleted_at TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, - UNIQUE(session_id, worktree_id) - ); - - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); ` func (r *Repository) initSessionWorktreeSchema() error { diff --git a/apps/backend/internal/task/repository/sqlite/heal_test.go b/apps/backend/internal/task/repository/sqlite/heal_test.go index 2a43e60c70..0a2a0ba7ba 100644 --- a/apps/backend/internal/task/repository/sqlite/heal_test.go +++ b/apps/backend/internal/task/repository/sqlite/heal_test.go @@ -49,14 +49,12 @@ func insertEnv(t *testing.T, db *sqlx.DB, env *models.TaskEnvironment) { } _, err := db.Exec(` INSERT INTO task_environments ( - id, task_id, repository_id, executor_type, executor_id, executor_profile_id, - control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, - created_at, updated_at - ) VALUES (?, ?, '', ?, '', '', 0, ?, '', ?, '', ?, '', '', ?, ?) + id, task_id, executor_type, executor_id, executor_profile_id, + control_port, status, workspace_path, + container_id, sandbox_id, created_at, updated_at + ) VALUES (?, ?, '', ?, '', 0, ?, ?, '', '', ?, ?) `, env.ID, env.TaskID, env.ExecutorType, string(env.Status), - env.WorktreePath, env.WorkspacePath, env.CreatedAt, env.UpdatedAt) + env.WorkspacePath, env.CreatedAt, env.UpdatedAt) if err != nil { t.Fatalf("insert env: %v", err) } @@ -74,143 +72,6 @@ func insertTask(t *testing.T, db *sqlx.DB, taskID string) { } } -// TestHealTaskEnvironmentWorkspacePaths_BackfillsEmpty seeds a worktree-mode -// env with worktree_path set but workspace_path empty (the corrupt state seen -// in the live DB) and asserts the heal step backfills workspace_path from -// worktree_path. -func TestHealTaskEnvironmentWorkspacePaths_BackfillsEmpty(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-A") - insertEnv(t, repo.db, &models.TaskEnvironment{ - ID: "env-A", - TaskID: "task-A", - ExecutorType: "worktree", - WorktreePath: "/home/user/.kandev/worktrees/foo", - // WorkspacePath intentionally empty. - }) - - if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil { - t.Fatalf("heal: %v", err) - } - - got, err := repo.GetTaskEnvironment(context.Background(), "env-A") - if err != nil { - t.Fatalf("get env: %v", err) - } - if got.WorkspacePath != "/home/user/.kandev/worktrees/foo" { - t.Errorf("workspace_path = %q, want it backfilled from worktree_path", got.WorkspacePath) - } -} - -// TestHealTaskEnvironmentWorkspacePaths_RepairsCollapsedParent — a row where -// workspace_path was the task-root parent of worktree_path (the pre-fix value -// left by legacy computeWorkspacePath's filepath.Dir) must be repaired: -// workspace_path becomes worktree_path so ACP session/load finds the agent's -// saved jsonl on cold start. -func TestHealTaskEnvironmentWorkspacePaths_RepairsCollapsedParent(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-B") - insertEnv(t, repo.db, &models.TaskEnvironment{ - ID: "env-B", - TaskID: "task-B", - ExecutorType: "worktree", - WorktreePath: "/home/user/.kandev/tasks/foo_abc/repo", - WorkspacePath: "/home/user/.kandev/tasks/foo_abc", // legacy collapsed parent - }) - - if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil { - t.Fatalf("heal: %v", err) - } - - got, err := repo.GetTaskEnvironment(context.Background(), "env-B") - if err != nil { - t.Fatalf("get env: %v", err) - } - if got.WorkspacePath != "/home/user/.kandev/tasks/foo_abc/repo" { - t.Errorf("workspace_path = %q, want repaired to worktree_path subdir", got.WorkspacePath) - } -} - -// TestHealTaskEnvironmentWorkspacePaths_LeavesAlreadyCorrectAlone — a row that -// already has workspace_path == worktree_path must not be touched. -func TestHealTaskEnvironmentWorkspacePaths_LeavesAlreadyCorrectAlone(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-B2") - insertEnv(t, repo.db, &models.TaskEnvironment{ - ID: "env-B2", - TaskID: "task-B2", - ExecutorType: "worktree", - WorktreePath: "/home/user/.kandev/tasks/foo_abc/repo", - WorkspacePath: "/home/user/.kandev/tasks/foo_abc/repo", - }) - - if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil { - t.Fatalf("heal: %v", err) - } - - got, err := repo.GetTaskEnvironment(context.Background(), "env-B2") - if err != nil { - t.Fatalf("get env: %v", err) - } - if got.WorkspacePath != "/home/user/.kandev/tasks/foo_abc/repo" { - t.Errorf("workspace_path = %q, must not be overwritten", got.WorkspacePath) - } -} - -// TestHealTaskEnvironmentWorkspacePaths_TaskDirMode — task-dir-mode envs -// place the worktree at /.kandev/tasks//; workspace_path -// must equal worktree_path (the repo subdir), matching the agent process cwd -// so ACP session/load on cold start hits the same sanitised-cwd folder. -func TestHealTaskEnvironmentWorkspacePaths_TaskDirMode(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-T") - insertEnv(t, repo.db, &models.TaskEnvironment{ - ID: "env-T", - TaskID: "task-T", - ExecutorType: "worktree", - WorktreePath: "/home/u/.kandev/tasks/fix-something_abc/kandev", - }) - - if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil { - t.Fatalf("heal: %v", err) - } - - got, err := repo.GetTaskEnvironment(context.Background(), "env-T") - if err != nil { - t.Fatalf("get env: %v", err) - } - if got.WorkspacePath != "/home/u/.kandev/tasks/fix-something_abc/kandev" { - t.Errorf("workspace_path = %q, want worktree_path (subdir) — process cwd parity", got.WorkspacePath) - } -} - -// TestHealTaskEnvironmentWorkspacePaths_Idempotent — running the heal twice -// must not change anything on the second run. -func TestHealTaskEnvironmentWorkspacePaths_Idempotent(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-C") - insertEnv(t, repo.db, &models.TaskEnvironment{ - ID: "env-C", - TaskID: "task-C", - ExecutorType: "worktree", - WorktreePath: "/x", - }) - - for i := 0; i < 2; i++ { - if err := repo.healTaskEnvironmentWorkspacePaths(); err != nil { - t.Fatalf("heal pass %d: %v", i, err) - } - } - - got, err := repo.GetTaskEnvironment(context.Background(), "env-C") - if err != nil { - t.Fatalf("get env: %v", err) - } - if got.WorkspacePath != "/x" { - t.Errorf("workspace_path = %q, want /x after idempotent run", got.WorkspacePath) - } -} - // TestHealDuplicateTaskEnvironments_KeepsMostRecent seeds two envs for the // same task (the race the user hit) with sessions pointing at the older one, // runs the heal, and asserts: only the newer env remains, sessions have been @@ -231,13 +92,13 @@ func TestHealDuplicateTaskEnvironments_KeepsMostRecent(t *testing.T) { newer := older.Add(5 * time.Second) insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-old", TaskID: "task-D", ExecutorType: "worktree", - WorktreePath: "/old", WorkspacePath: "/old", - CreatedAt: older, UpdatedAt: older, + WorkspacePath: "/old", + CreatedAt: older, UpdatedAt: older, }) insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-new", TaskID: "task-D", ExecutorType: "worktree", - WorktreePath: "/new", WorkspacePath: "/new", - CreatedAt: newer, UpdatedAt: newer, + WorkspacePath: "/new", + CreatedAt: newer, UpdatedAt: newer, }) // A session created against the loser env — must be re-linked. if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ @@ -283,7 +144,7 @@ func TestHealDuplicateTaskEnvironments_NoOpWhenSingle(t *testing.T) { insertTask(t, repo.db, "task-E") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-E", TaskID: "task-E", ExecutorType: "worktree", - WorktreePath: "/e", WorkspacePath: "/e", + WorkspacePath: "/e", }) if err := repo.healDuplicateTaskEnvironments(); err != nil { @@ -307,20 +168,19 @@ func TestEnsureTaskEnvironmentTaskUniqueIndex_BlocksFutureDuplicates(t *testing. insertTask(t, repo.db, "task-F") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-F1", TaskID: "task-F", ExecutorType: "worktree", - WorktreePath: "/f", WorkspacePath: "/f", + WorkspacePath: "/f", }) // initSchema already added the unique index; a second insert for the same - // task must fail with a constraint error. (agent_execution_id was dropped - // from task_environments — the column reference is gone here too.) + // task must fail with a constraint error. The flat worktree columns are + // gone; only the final columns remain. _, err := repo.db.Exec(` INSERT INTO task_environments ( - id, task_id, repository_id, executor_type, executor_id, executor_profile_id, - control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, + id, task_id, executor_type, executor_id, executor_profile_id, + control_port, status, workspace_path, container_id, sandbox_id, created_at, updated_at - ) VALUES (?, 'task-F', '', 'worktree', '', '', 0, 'ready', - '', '/f2', '', '/f2', '', '', datetime('now'), datetime('now')) + ) VALUES (?, 'task-F', 'worktree', '', '', 0, 'ready', '/f2', + '', '', datetime('now'), datetime('now')) `, "env-F2") if err == nil { t.Fatal("expected unique-constraint error inserting second env for same task_id") @@ -350,7 +210,6 @@ func TestCreateTaskEnvironment_RejectsEmptyWorkspaceForWorktree(t *testing.T) { err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{ TaskID: "task-G", ExecutorType: "worktree", - WorktreePath: "/g", // WorkspacePath intentionally empty. }) if err == nil { @@ -382,7 +241,6 @@ func TestCreateTaskEnvironment_AllowsSameRepoMultiBranchRows(t *testing.T) { ID: "env-H2", TaskID: "task-H2", ExecutorType: string(models.ExecutorTypeWorktree), - WorktreePath: "/workspace/main", WorkspacePath: "/workspace", Repos: []*models.TaskEnvironmentRepo{ { @@ -426,14 +284,14 @@ func TestUpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree(t *testing.T) insertTask(t, repo.db, "task-I") if err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{ ID: "env-I", TaskID: "task-I", ExecutorType: "worktree", - WorktreePath: "/i", WorkspacePath: "/i", + WorkspacePath: "/i", }); err != nil { t.Fatalf("seed: %v", err) } err := repo.UpdateTaskEnvironment(context.Background(), &models.TaskEnvironment{ ID: "env-I", TaskID: "task-I", ExecutorType: "worktree", - WorktreePath: "/i", WorkspacePath: "", + WorkspacePath: "", }) if err == nil { t.Fatal("expected update to fail when clearing workspace_path on worktree env") @@ -495,7 +353,7 @@ func TestHealSessionTaskEnvironmentIDs_LinksOrphans(t *testing.T) { insertTask(t, repo.db, "task-J") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-J", TaskID: "task-J", ExecutorType: "worktree", - WorktreePath: "/j", WorkspacePath: "/j", + WorkspacePath: "/j", }) insertSessionWithEnvID(t, repo.db, "sess-J-orphan", "task-J", "") @@ -519,7 +377,7 @@ func TestHealSessionTaskEnvironmentIDs_LeavesLinkedAlone(t *testing.T) { insertTask(t, repo.db, "task-K") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-K", TaskID: "task-K", ExecutorType: "worktree", - WorktreePath: "/k", WorkspacePath: "/k", + WorkspacePath: "/k", }) insertSessionWithEnvID(t, repo.db, "sess-K-linked", "task-K", "env-K") @@ -565,7 +423,7 @@ func TestHealSessionTaskEnvironmentIDs_LinksNullOrphans(t *testing.T) { insertTask(t, repo.db, "task-N") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-N", TaskID: "task-N", ExecutorType: "worktree", - WorktreePath: "/n", WorkspacePath: "/n", + WorkspacePath: "/n", }) insertSessionWithNullEnvID(t, repo.db, "sess-N-null", "task-N") @@ -589,7 +447,7 @@ func TestHealSessionTaskEnvironmentIDs_Idempotent(t *testing.T) { insertTask(t, repo.db, "task-M") insertEnv(t, repo.db, &models.TaskEnvironment{ ID: "env-M", TaskID: "task-M", ExecutorType: "worktree", - WorktreePath: "/m", WorkspacePath: "/m", + WorkspacePath: "/m", }) insertSessionWithEnvID(t, repo.db, "sess-M", "task-M", "") @@ -610,40 +468,3 @@ func TestHealSessionTaskEnvironmentIDs_Idempotent(t *testing.T) { // silences "imported and not used" if some future refactor drops a use. var _ = sql.ErrNoRows - -// TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow — when the -// referenced executor row is genuinely absent (legacy session whose executor -// was deleted), backfillSingleTask must default executor_type to "local_pc" -// and continue. Locks in the narrowed error-handling: only sql.ErrNoRows -// triggers the default; any other scan error must propagate so operators see -// the real cause instead of every backfilled env silently getting the wrong -// type. -func TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow(t *testing.T) { - repo := newRepoForHealTests(t) - insertTask(t, repo.db, "task-bf") - // Session references an executor id that does NOT exist in `executors`. - // backfillTaskEnvironments queries task_sessions for orphans (no env) - // and calls backfillSingleTask for each — exercising the executor - // lookup path with sql.ErrNoRows. - insertSessionWithEnvID(t, repo.db, "sess-bf", "task-bf", "") - if _, err := repo.db.Exec( - `UPDATE task_sessions SET executor_id = 'exec-deleted', started_at = ? WHERE id = 'sess-bf'`, - time.Now().UTC(), - ); err != nil { - t.Fatalf("seed executor_id: %v", err) - } - - if err := repo.backfillTaskEnvironments(); err != nil { - t.Fatalf("backfillTaskEnvironments: %v", err) - } - - var executorType string - if err := repo.db.QueryRow( - `SELECT executor_type FROM task_environments WHERE task_id = 'task-bf'`, - ).Scan(&executorType); err != nil { - t.Fatalf("scan: %v", err) - } - if executorType != "local_pc" { - t.Errorf("executor_type = %q, want default 'local_pc' when executor row absent", executorType) - } -} diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 92675e0877..6905f28286 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -399,7 +399,12 @@ func TestPostgresUpdateSessionContextWindowCountsStrictUsageDrops(t *testing.T) } } -func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) { +// TestPostgresNormalizeTaskWorktreeOwnershipIsNoOpOnFreshSchema proves the +// one-time cutover is a no-op on a database that already carries the final +// schema: the legacy table does not exist, so nothing is normalized and the +// orphaned session stays untouched (the startup heal +// healSessionTaskEnvironmentIDs owns that repair on final-schema databases). +func TestPostgresNormalizeTaskWorktreeOwnershipIsNoOpOnFreshSchema(t *testing.T) { db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) repo, err := NewWithDB(db, db, nil) if err != nil { @@ -420,8 +425,8 @@ func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) { t.Fatalf("insert orphaned session: %v", err) } - if err := repo.backfillTaskEnvironments(); err != nil { - t.Fatalf("backfill task environments: %v", err) + if err := repo.normalizeTaskWorktreeOwnership(); err != nil { + t.Fatalf("normalize task worktree ownership: %v", err) } var count int @@ -431,7 +436,7 @@ func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) { t.Fatalf("count task environments: %v", err) } if count != 0 { - t.Fatalf("task environment count = %d, want 0", count) + t.Fatalf("task environment count = %d, want 0 (cutover is a no-op on the final schema)", count) } } diff --git a/apps/backend/internal/task/repository/sqlite/resource_cleanup.go b/apps/backend/internal/task/repository/sqlite/resource_cleanup.go index 06bd9e1ef8..f1fafa7af5 100644 --- a/apps/backend/internal/task/repository/sqlite/resource_cleanup.go +++ b/apps/backend/internal/task/repository/sqlite/resource_cleanup.go @@ -36,6 +36,26 @@ func (r *Repository) CreateTaskResourceCleanupJob(ctx context.Context, job *mode return err } +// UpdateTaskResourceCleanupSnapshot writes the resource inventory captured +// after the prepared barrier was reserved. The barrier row exists before the +// inventory query so concurrent session/worktree creation is rejected while +// the snapshot is being assembled. +func (r *Repository) UpdateTaskResourceCleanupSnapshot(ctx context.Context, operationID, snapshot string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE task_resource_cleanup_jobs + SET resource_snapshot = ?, updated_at = ? + WHERE operation_id = ? AND state = ? + `), snapshot, time.Now().UTC(), operationID, models.TaskResourceCleanupStatePrepared) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task resource cleanup job %s not found or not prepared", operationID) + } + return nil +} + // HasActiveTaskResourceCleanupJob reports whether teardown has been admitted // for a task. The prepared state is included because the cleanup intent is // persisted before task deletion and before the worker is allowed to run. diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 6721d7dba2..9abbbfb2e9 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -213,7 +213,22 @@ const taskSessionFromClause = `FROM task_sessions ts // CreateTaskSession creates a new agent session func (r *Repository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error { - return r.createTaskSession(ctx, r.db, session) + // The task cleanup barrier serializes session creation against archive/ + // delete preparation (ADR-2026-08-08): PostgreSQL takes a row lock on the + // task, SQLite serializes the writer transaction. A creation admitted + // after cleanup inventory was captured would be missed by the snapshot. + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := r.taskCleanupBarrierLocked(ctx, tx, session.TaskID); err != nil { + return err + } + if err := r.createTaskSession(ctx, tx, session); err != nil { + return err + } + return tx.Commit() } // CreateOfficeTaskSession creates an Office session and atomically marks it as @@ -1892,68 +1907,106 @@ func (r *Repository) DeleteTaskSession(ctx context.Context, id string) error { } // Task Session Worktree operations +// +// Sessions reference worktrees only through task_sessions.task_environment_id; +// the physical worktree records live on task_environment_repos (owned by the +// task environment, not the session). All session-scoped queries below join +// through that link. -func (r *Repository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error { - if sessionWorktree.ID == "" { - sessionWorktree.ID = uuid.New().String() +// envRepoSelectCols is the SELECT projection for a task_environment_repos row +// in session-scoped worktree queries. +const envRepoSelectCols = ` + ter.id, ter.task_environment_id, ter.repository_id, + COALESCE(ter.branch_slug, ''), COALESCE(ter.worktree_id, ''), + COALESCE(ter.worktree_path, ''), COALESCE(ter.worktree_branch, ''), + ter.position, COALESCE(ter.error_message, ''), ter.status, + ter.created_at, ter.updated_at, ter.merged_at, ter.deleted_at` + +// scanEnvRepoRow scans one task_environment_repos row into a +// models.TaskEnvironmentRepo. +func scanEnvRepoRow(scanner rowScanner) (*models.TaskEnvironmentRepo, error) { + row := &models.TaskEnvironmentRepo{} + var mergedAt, deletedAt sql.NullTime + if err := scanner.Scan( + &row.ID, &row.TaskEnvironmentID, &row.RepositoryID, &row.BranchSlug, + &row.WorktreeID, &row.WorktreePath, &row.WorktreeBranch, &row.Position, + &row.ErrorMessage, &row.Status, &row.CreatedAt, &row.UpdatedAt, + &mergedAt, &deletedAt, + ); err != nil { + return nil, err } - now := time.Now().UTC() - sessionWorktree.CreatedAt = now - updatedAt := now + if mergedAt.Valid { + t := mergedAt.Time + row.MergedAt = &t + } + if deletedAt.Valid { + t := deletedAt.Time + row.DeletedAt = &t + } + return row, nil +} - _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO task_session_worktrees ( - id, session_id, worktree_id, repository_id, branch_slug, position, - worktree_path, worktree_branch, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id, worktree_id) DO UPDATE SET - repository_id = excluded.repository_id, - branch_slug = CASE - WHEN excluded.branch_slug != '' THEN excluded.branch_slug - ELSE task_session_worktrees.branch_slug - END, - position = excluded.position, - worktree_path = excluded.worktree_path, - worktree_branch = excluded.worktree_branch, - updated_at = excluded.updated_at - `), - sessionWorktree.ID, - sessionWorktree.SessionID, - sessionWorktree.WorktreeID, - sessionWorktree.RepositoryID, - sessionWorktree.BranchSlug, - sessionWorktree.Position, - sessionWorktree.WorktreePath, - sessionWorktree.WorktreeBranch, - sessionWorktree.CreatedAt, - updatedAt, - ) - return err +// rowScanner abstracts *sql.Row and *sql.Rows so env-repo rows scan through +// one helper. +type rowScanner interface { + Scan(dest ...interface{}) error +} + +// ListTaskSessionWorktrees returns the active environment-repository rows of +// the session's task environment. Sessions sharing an environment observe the +// same worktrees. +func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+envRepoSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_sessions ts ON ts.task_environment_id = ter.task_environment_id + WHERE ts.id = ? + AND ter.deleted_at IS NULL + AND ter.status = 'active' + ORDER BY ter.position ASC, ter.created_at ASC + `), sessionID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var repos []*models.TaskEnvironmentRepo + for rows.Next() { + row, err := scanEnvRepoRow(rows) + if err != nil { + return nil, err + } + repos = append(repos, row) + } + return repos, rows.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. +// repository rows of the session's task environment. 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 = ? + UPDATE task_environment_repos SET worktree_branch = ?, updated_at = ? + WHERE task_environment_id = (SELECT task_environment_id FROM task_sessions WHERE id = ?) + AND deleted_at IS NULL + AND status = 'active' `), branch, now, sessionID) return err } // UpdateTaskSessionWorktreeBranchByRepository updates the cached worktree_branch -// for one repository row in a session. Use this for repo-scoped live git -// operations in multi-repo tasks so sibling repositories keep their branch -// snapshots. +// for one repository row in the session's environment. Use this for repo-scoped +// live git operations in multi-repo tasks so sibling repositories keep their +// branch snapshots. func (r *Repository) UpdateTaskSessionWorktreeBranchByRepository(ctx context.Context, sessionID, repositoryID, branch string) error { now := time.Now().UTC() _, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE task_session_worktrees + UPDATE task_environment_repos SET worktree_branch = ?, updated_at = ? - WHERE session_id = ? + WHERE task_environment_id = (SELECT task_environment_id FROM task_sessions WHERE id = ?) AND repository_id = ? AND deleted_at IS NULL AND status = 'active' @@ -1967,9 +2020,9 @@ func (r *Repository) UpdateTaskSessionWorktreeBranchByRepository(ctx context.Con 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 + UPDATE task_environment_repos SET worktree_branch = ?, updated_at = ? - WHERE session_id = ? + WHERE task_environment_id = (SELECT task_environment_id FROM task_sessions WHERE id = ?) AND worktree_id = ? AND deleted_at IS NULL AND status = 'active' @@ -1977,54 +2030,15 @@ func (r *Repository) UpdateTaskSessionWorktreeBranchByWorktree(ctx context.Conte return err } -func (r *Repository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { - rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT - tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, - COALESCE(tsw.branch_slug, ''), tsw.position, - tsw.worktree_path, tsw.worktree_branch, tsw.created_at - FROM task_session_worktrees tsw - WHERE tsw.session_id = ? - AND tsw.deleted_at IS NULL - AND tsw.status = 'active' - ORDER BY tsw.position ASC, tsw.created_at ASC - `), sessionID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var worktrees []*models.TaskSessionWorktree - for rows.Next() { - var wt models.TaskSessionWorktree - err := rows.Scan( - &wt.ID, - &wt.SessionID, - &wt.WorktreeID, - &wt.RepositoryID, - &wt.BranchSlug, - &wt.Position, - &wt.WorktreePath, - &wt.WorktreeBranch, - &wt.CreatedAt, - ) - if err != nil { - return nil, err - } - worktrees = append(worktrees, &wt) - } - return worktrees, rows.Err() -} - -// ListWorktreesBySessionIDs returns all worktrees for the given session IDs, -// grouped by session ID. This eliminates N+1 queries when loading worktrees -// for multiple sessions. Chunks input above sqliteMaxHostParams (500) because -// callers like loadWorktreesBatch — invoked from BatchGetSessionsByTaskIDs — -// can pass `chunk_size_tasks × avg_sessions_per_task` IDs, which crosses -// SQLite's SQLITE_MAX_VARIABLE_NUMBER (999 on older builds) at modest task -// volumes (500 tasks × 2 sessions = 1000 placeholders). -func (r *Repository) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskSessionWorktree, error) { - result := make(map[string][]*models.TaskSessionWorktree, len(sessionIDs)) +// ListWorktreesBySessionIDs returns the active environment-repository rows for +// the given session IDs, grouped by session ID. This eliminates N+1 queries +// when loading worktrees for multiple sessions. Chunks input above +// sqliteMaxHostParams (500) because callers like loadWorktreesBatch — invoked +// from BatchGetSessionsByTaskIDs — can pass +// `chunk_size_tasks × avg_sessions_per_task` IDs, which crosses SQLite's +// SQLITE_MAX_VARIABLE_NUMBER (999 on older builds) at modest task volumes. +func (r *Repository) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs []string) (map[string][]*models.TaskEnvironmentRepo, error) { + result := make(map[string][]*models.TaskEnvironmentRepo, len(sessionIDs)) if len(sessionIDs) == 0 { return result, nil } @@ -2043,16 +2057,16 @@ func (r *Repository) ListWorktreesBySessionIDs(ctx context.Context, sessionIDs [ func (r *Repository) appendWorktreesForSessionChunk( ctx context.Context, sessionIDs []string, - result map[string][]*models.TaskSessionWorktree, + result map[string][]*models.TaskEnvironmentRepo, ) error { placeholders, args := buildInPlaceholders(sessionIDs) - query := `SELECT tsw.id, tsw.session_id, tsw.worktree_id, tsw.repository_id, tsw.position, - COALESCE(tsw.branch_slug, ''), tsw.worktree_path, tsw.worktree_branch, tsw.created_at - FROM task_session_worktrees tsw - WHERE tsw.session_id IN (` + placeholders + `) - AND tsw.deleted_at IS NULL - AND tsw.status = 'active' - ORDER BY tsw.position ASC, tsw.created_at ASC` + query := `SELECT ts.id AS session_id, ` + envRepoSelectCols + ` + FROM task_environment_repos ter + INNER JOIN task_sessions ts ON ts.task_environment_id = ter.task_environment_id + WHERE ts.id IN (` + placeholders + `) + AND ter.deleted_at IS NULL + AND ter.status = 'active' + ORDER BY ter.position ASC, ter.created_at ASC` rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { @@ -2061,34 +2075,28 @@ func (r *Repository) appendWorktreesForSessionChunk( defer func() { _ = rows.Close() }() for rows.Next() { - var wt models.TaskSessionWorktree - if err := rows.Scan(&wt.ID, &wt.SessionID, &wt.WorktreeID, &wt.RepositoryID, - &wt.Position, &wt.BranchSlug, &wt.WorktreePath, &wt.WorktreeBranch, &wt.CreatedAt); err != nil { + var sessionID string + var mergedAt, deletedAt sql.NullTime + row := &models.TaskEnvironmentRepo{} + if err := rows.Scan(&sessionID, &row.ID, &row.TaskEnvironmentID, &row.RepositoryID, + &row.BranchSlug, &row.WorktreeID, &row.WorktreePath, &row.WorktreeBranch, + &row.Position, &row.ErrorMessage, &row.Status, &row.CreatedAt, &row.UpdatedAt, + &mergedAt, &deletedAt); err != nil { return err } - result[wt.SessionID] = append(result[wt.SessionID], &wt) + if mergedAt.Valid { + t := mergedAt.Time + row.MergedAt = &t + } + if deletedAt.Valid { + t := deletedAt.Time + row.DeletedAt = &t + } + result[sessionID] = append(result[sessionID], row) } return rows.Err() } -func (r *Repository) DeleteTaskSessionWorktree(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE id = ?`), id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task session worktree not found: %s", id) - } - return nil -} - -func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error { - _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_session_worktrees WHERE session_id = ?`), sessionID) - return err -} - // GetPrimarySessionByTaskID retrieves the primary session for a task. // Returns ErrNoPrimarySession (wrapped) when the task has no primary session // row; callers should use errors.Is to distinguish this from real DB errors. diff --git a/apps/backend/internal/task/repository/sqlite/session_branch.go b/apps/backend/internal/task/repository/sqlite/session_branch.go index 68acaa64f8..d0d52f627a 100644 --- a/apps/backend/internal/task/repository/sqlite/session_branch.go +++ b/apps/backend/internal/task/repository/sqlite/session_branch.go @@ -7,18 +7,20 @@ import ( ) // ListSessionsWithBranches returns sessions that have worktree branches -// on non-archived tasks. Used by the PR watch reconciler. +// on non-archived tasks. Used by the PR watch reconciler. Branches live on +// task_environment_repos; sessions reach them through +// task_sessions.task_environment_id. func (r *Repository) ListSessionsWithBranches(ctx context.Context) ([]models.SessionBranchInfo, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT ts.id, ts.task_id, t.workspace_id, tsw.worktree_branch + SELECT ts.id, ts.task_id, t.workspace_id, ter.worktree_branch FROM task_sessions ts INNER JOIN tasks t ON t.id = ts.task_id - INNER JOIN task_session_worktrees tsw ON tsw.session_id = ts.id + INNER JOIN task_environment_repos ter ON ter.task_environment_id = ts.task_environment_id WHERE t.archived_at IS NULL - AND tsw.worktree_branch != '' - AND tsw.deleted_at IS NULL - AND tsw.status = 'active' - GROUP BY ts.id + AND ter.worktree_branch != '' + AND ter.deleted_at IS NULL + AND ter.status = 'active' + GROUP BY ts.id, ts.task_id, t.workspace_id, ter.worktree_branch ORDER BY ts.started_at DESC `) if err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index abd6d403c3..771fe753a3 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -57,6 +57,11 @@ func TestTaskSessionWorkspacePathUsesCurrentEnvironmentRoot(t *testing.T) { ExecutorType: string(models.ExecutorTypeWorktree), Status: models.TaskEnvironmentStatusReady, WorkspacePath: "/task-root/kandev", + Repos: []*models.TaskEnvironmentRepo{{ + RepositoryID: "repo-root", + WorktreeID: "worktree-primary", + WorktreePath: "/task-root/kandev", + }}, }); err != nil { t.Fatalf("CreateTaskEnvironment: %v", err) } @@ -68,16 +73,6 @@ func TestTaskSessionWorkspacePathUsesCurrentEnvironmentRoot(t *testing.T) { }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "session-worktree-root", - SessionID: sessionID, - WorktreeID: "worktree-primary", - WorktreePath: "/task-root/kandev", - Position: 0, - }); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) - } - env, err := repo.GetTaskEnvironment(ctx, envID) if err != nil { t.Fatalf("GetTaskEnvironment: %v", err) @@ -709,44 +704,46 @@ func TestListTaskSessionWorktreesFiltersInactiveRows(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() seedForMsgTest(t, repo, "task-worktrees", "session-worktrees", "turn-worktrees") - worktrees := []*models.TaskSessionWorktree{ + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-worktrees", TaskID: "task-worktrees", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), + "env-worktrees", "session-worktrees"); err != nil { + t.Fatalf("link session to env: %v", err) + } + worktrees := []*models.TaskEnvironmentRepo{ { - ID: "wt-active", - SessionID: "session-worktrees", - WorktreeID: "worktree-active", - RepositoryID: "repo-1", - BranchSlug: "main", + ID: "wt-active", TaskEnvironmentID: "env-worktrees", + WorktreeID: "worktree-active", RepositoryID: "repo-1", BranchSlug: "main", }, { - ID: "wt-status-deleted", - SessionID: "session-worktrees", - WorktreeID: "worktree-status-deleted", - RepositoryID: "repo-1", - BranchSlug: "deleted-status", + ID: "wt-status-deleted", TaskEnvironmentID: "env-worktrees", + WorktreeID: "worktree-status-deleted", RepositoryID: "repo-1", BranchSlug: "deleted-status", }, { - ID: "wt-timestamp-deleted", - SessionID: "session-worktrees", - WorktreeID: "worktree-timestamp-deleted", - RepositoryID: "repo-1", - BranchSlug: "deleted-at", + ID: "wt-timestamp-deleted", TaskEnvironmentID: "env-worktrees", + WorktreeID: "worktree-timestamp-deleted", RepositoryID: "repo-1", BranchSlug: "deleted-at", }, } for _, wt := range worktrees { - if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { - t.Fatalf("CreateTaskSessionWorktree(%s): %v", wt.ID, err) + if err := repo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo(%s): %v", wt.ID, err) } } now := time.Now().UTC() if _, err := repo.db.Exec(repo.db.Rebind(` - UPDATE task_session_worktrees + UPDATE task_environment_repos SET status = 'deleted', updated_at = ? WHERE id = ? `), now, "wt-status-deleted"); err != nil { t.Fatalf("mark status deleted: %v", err) } if _, err := repo.db.Exec(repo.db.Rebind(` - UPDATE task_session_worktrees + UPDATE task_environment_repos SET deleted_at = ?, updated_at = ? WHERE id = ? `), now, now, "wt-timestamp-deleted"); err != nil { @@ -774,26 +771,27 @@ func TestUpdateTaskSessionWorktreeBranchByRepositoryScopesUpdate(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() seedForMsgTest(t, repo, "task-worktrees", "session-worktrees", "turn-worktrees") + linkSessionToEnvForTest(t, repo, "session-worktrees", "env-worktrees") - worktrees := []*models.TaskSessionWorktree{ + worktrees := []*models.TaskEnvironmentRepo{ { - ID: "wt-repo-1", - SessionID: "session-worktrees", - WorktreeID: "worktree-repo-1", - RepositoryID: "repo-1", - WorktreeBranch: "feature/old-one", + ID: "wt-repo-1", + TaskEnvironmentID: "env-worktrees", + WorktreeID: "worktree-repo-1", + RepositoryID: "repo-1", + WorktreeBranch: "feature/old-one", }, { - ID: "wt-repo-2", - SessionID: "session-worktrees", - WorktreeID: "worktree-repo-2", - RepositoryID: "repo-2", - WorktreeBranch: "feature/old-two", + ID: "wt-repo-2", + TaskEnvironmentID: "env-worktrees", + WorktreeID: "worktree-repo-2", + RepositoryID: "repo-2", + WorktreeBranch: "feature/old-two", }, } for _, wt := range worktrees { - if err := repo.CreateTaskSessionWorktree(ctx, wt); err != nil { - t.Fatalf("CreateTaskSessionWorktree(%s): %v", wt.ID, err) + if err := repo.CreateTaskEnvironmentRepo(ctx, wt); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo(%s): %v", wt.ID, err) } } @@ -821,11 +819,22 @@ func TestUpdateTaskSessionWorktreeBranchByWorktreeScopesRepeatedRepository(t *te 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}, + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-repeated-repo", TaskID: "task-repeated-repo", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), + "env-repeated-repo", "session-repeated-repo"); err != nil { + t.Fatalf("link session to env: %v", err) + } + for _, wt := range []*models.TaskEnvironmentRepo{ + {ID: "wt-repeated-one", TaskEnvironmentID: "env-repeated-repo", WorktreeID: "worktree-repeated-one", RepositoryID: "repo-repeated", BranchSlug: "one", WorktreeBranch: "feature/one", Position: 0}, + {ID: "wt-repeated-two", TaskEnvironmentID: "env-repeated-repo", WorktreeID: "worktree-repeated-two", RepositoryID: "repo-repeated", BranchSlug: "two", WorktreeBranch: "feature/two", Position: 1}, } { - require.NoError(t, repo.CreateTaskSessionWorktree(ctx, wt)) + require.NoError(t, repo.CreateTaskEnvironmentRepo(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") @@ -836,6 +845,24 @@ func TestUpdateTaskSessionWorktreeBranchByWorktreeScopesRepeatedRepository(t *te // TestGetLastAgentMessage_NoMessages verifies that a session with no messages // returns an empty string and sql.ErrNoRows. +// linkSessionToEnvForTest creates the environment row a worktree test +// references and points the session at it. +func linkSessionToEnvForTest(t *testing.T, repo *Repository, sessionID, envID string) { + t.Helper() + ctx := context.Background() + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: envID, TaskID: "task-worktrees", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + if _, err := repo.db.Exec(repo.db.Rebind( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), + envID, sessionID); err != nil { + t.Fatalf("link session to env: %v", err) + } +} + func TestGetLastAgentMessage_NoMessages(t *testing.T) { repo := newRepoForSessionTests(t) ctx := context.Background() diff --git a/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier.go b/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier.go new file mode 100644 index 0000000000..dcd96e62a4 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier.go @@ -0,0 +1,49 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// taskCleanupBarrierLocked serializes session/worktree creation against task +// lifecycle cleanup (ADR-2026-08-08). PostgreSQL takes a row lock on the +// owning task so a concurrent barrier reservation either commits before the +// creation and is observed, or blocks until the creation commits; SQLite's +// single-writer transaction is the serialization. Returns +// repoerrors.ErrTaskCleanupInProgress when a prepared/pending/running cleanup +// barrier exists for the task. +func (r *Repository) taskCleanupBarrierLocked(ctx context.Context, tx *sqlx.Tx, taskID string) error { + if dialect.IsPostgres(r.db.DriverName()) { + var lockedTaskID string + if err := tx.QueryRowContext(ctx, r.db.Rebind( + `SELECT id FROM tasks WHERE id = ? FOR UPDATE`, + ), taskID).Scan(&lockedTaskID); err != nil { + return fmt.Errorf("lock task for creation barrier: %w", err) + } + } + + var active bool + if err := tx.QueryRowContext(ctx, r.db.Rebind(` + SELECT EXISTS ( + SELECT 1 FROM task_resource_cleanup_jobs + WHERE task_id = ? AND state IN (?, ?, ?, ?) + ) + `), taskID, + models.TaskResourceCleanupStatePrepared, + models.TaskResourceCleanupStatePending, + models.TaskResourceCleanupStateRunning, + models.TaskResourceCleanupStateRetryWait, + ).Scan(&active); err != nil { + return fmt.Errorf("check task cleanup barrier: %w", err) + } + if active { + return fmt.Errorf("%w: %s", repoerrors.ErrTaskCleanupInProgress, taskID) + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier_test.go b/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier_test.go new file mode 100644 index 0000000000..cae9d19137 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_cleanup_barrier_test.go @@ -0,0 +1,215 @@ +package sqlite + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/testutil" +) + +func newBarrierTestRepo(t *testing.T) *Repository { + t.Helper() + dbConn, err := dbutil.OpenSQLite(filepath.Join(t.TempDir(), "barrier.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + return repo +} + +func seedBarrierTask(t *testing.T, repo *Repository, taskID string) { + t.Helper() + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, 'ws-1', 'barrier task', ?, ?)`), taskID, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } +} + +func reserveBarrier(t *testing.T, repo *Repository, taskID, operationID string) { + t.Helper() + ctx := context.Background() + job := &models.TaskResourceCleanupJob{ + OperationID: operationID, TaskID: taskID, + Trigger: models.TaskResourceCleanupTriggerArchive, + State: models.TaskResourceCleanupStatePrepared, + } + if err := repo.CreateTaskResourceCleanupJob(ctx, job); err != nil { + t.Fatalf("reserve barrier: %v", err) + } +} + +func TestTaskCleanupBarrier_RejectsSessionCreation(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-session") + reserveBarrier(t, repo, "task-barrier-session", "op-session") + + err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-barrier", TaskID: "task-barrier-session", + State: models.TaskSessionStateCreated, + }) + if !errors.Is(err, repoerrors.ErrTaskCleanupInProgress) { + t.Fatalf("CreateTaskSession error = %v, want ErrTaskCleanupInProgress", err) + } +} + +func TestTaskCleanupBarrier_RejectsEnvironmentCreation(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-env") + reserveBarrier(t, repo, "task-barrier-env", "op-env") + + err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-barrier", TaskID: "task-barrier-env", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }) + if !errors.Is(err, repoerrors.ErrTaskCleanupInProgress) { + t.Fatalf("CreateTaskEnvironment error = %v, want ErrTaskCleanupInProgress", err) + } +} + +func TestTaskCleanupBarrier_RejectsEnvRepoCreation(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-repo") + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-barrier-repo", TaskID: "task-barrier-repo", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("seed env: %v", err) + } + reserveBarrier(t, repo, "task-barrier-repo", "op-repo") + + err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-barrier-repo", RepositoryID: "repo-1", + WorktreeID: "wt-1", WorktreePath: "/tmp/wt-1", + }) + if !errors.Is(err, repoerrors.ErrTaskCleanupInProgress) { + t.Fatalf("CreateTaskEnvironmentRepo error = %v, want ErrTaskCleanupInProgress", err) + } +} + +func TestTaskCleanupBarrier_AllowsCreationWithoutBarrier(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-open") + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-open", TaskID: "task-barrier-open", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession without barrier: %v", err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-open", TaskID: "task-barrier-open", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment without barrier: %v", err) + } + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + TaskEnvironmentID: "env-open", RepositoryID: "repo-1", + WorktreeID: "wt-1", WorktreePath: "/tmp/wt-1", + }); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo without barrier: %v", err) + } +} + +// TestTaskCleanupBarrier_CommittedCreationIsIncludedInInventory proves the +// inverse race ordering: a session/worktree that commits before the barrier +// is reserved remains visible to the later inventory. +func TestTaskCleanupBarrier_CommittedCreationIsIncludedInInventory(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-order") + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-order", TaskID: "task-barrier-order", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + reserveBarrier(t, repo, "task-barrier-order", "op-order") + + sessions, err := repo.ListTaskSessions(ctx, "task-barrier-order") + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(sessions) != 1 || sessions[0].ID != "session-order" { + t.Fatalf("inventory = %+v, want the committed session", sessions) + } +} + +// TestTaskCleanupBarrier_ReleasedBarrierAllowsCreation proves a completed or +// cancelled barrier no longer blocks session creation. +func TestTaskCleanupBarrier_ReleasedBarrierAllowsCreation(t *testing.T) { + repo := newBarrierTestRepo(t) + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-released") + reserveBarrier(t, repo, "task-barrier-released", "op-released") + job, err := repo.GetTaskResourceCleanupJobByOperationID(ctx, "op-released") + if err != nil { + t.Fatalf("get job: %v", err) + } + if err := repo.CompleteTaskResourceCleanupJob(ctx, job.ID, models.TaskResourceCleanupStateCancelled, "", nil); err != nil { + t.Fatalf("cancel barrier: %v", err) + } + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-released", TaskID: "task-barrier-released", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession after barrier release: %v", err) + } +} + +func TestTaskCleanupBarrierPostgres_RejectsSessionCreation(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres: %v", err) + } + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-pg") + reserveBarrier(t, repo, "task-barrier-pg", "op-pg") + + err = repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-pg", TaskID: "task-barrier-pg", State: models.TaskSessionStateCreated, + }) + if !errors.Is(err, repoerrors.ErrTaskCleanupInProgress) { + t.Fatalf("postgres CreateTaskSession error = %v, want ErrTaskCleanupInProgress", err) + } +} + +func TestTaskCleanupBarrierPostgres_AllowsCreationWithoutBarrier(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres: %v", err) + } + ctx := context.Background() + seedBarrierTask(t, repo, "task-barrier-pg-open") + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-pg-open", TaskID: "task-barrier-pg-open", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("postgres CreateTaskSession without barrier: %v", err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-pg-open", TaskID: "task-barrier-pg-open", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("postgres CreateTaskEnvironment without barrier: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index bfafd8df88..2ffed1f582 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/task/models" ) @@ -28,24 +29,30 @@ func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.Task env.CreatedAt = now env.UpdatedAt = now - tx, err := r.db.BeginTx(ctx, nil) + tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() + // Serialize environment creation against the task cleanup barrier so a + // worktree admitted after inventory capture cannot be missed. + if err := r.taskCleanupBarrierLocked(ctx, tx, env.TaskID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environments ( - id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + id, task_id, executor_type, executor_id, executor_profile_id, control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, + workspace_path, container_id, sandbox_id, task_dir_name, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), - env.ID, env.TaskID, env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, + env.ID, env.TaskID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(env.Status), - env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, + env.WorkspacePath, env.ContainerID, env.SandboxID, env.TaskDirName, env.CreatedAt, env.UpdatedAt, ); err != nil { @@ -68,16 +75,16 @@ func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models var status string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + SELECT id, task_id, executor_type, executor_id, executor_profile_id, control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, + workspace_path, container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE id = ? `), id).Scan( - &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, + &env.ID, &env.TaskID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.ControlPort, &status, - &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, + &env.WorkspacePath, &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) @@ -104,16 +111,16 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri var status string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + SELECT id, task_id, executor_type, executor_id, executor_profile_id, control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, + workspace_path, container_id, sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE task_id = ? ORDER BY created_at DESC LIMIT 1 `), taskID).Scan( - &env.ID, &env.TaskID, &env.RepositoryID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, + &env.ID, &env.TaskID, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.ControlPort, &status, - &env.WorktreeID, &env.WorktreePath, &env.WorktreeBranch, &env.WorkspacePath, + &env.WorkspacePath, &env.ContainerID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) @@ -146,16 +153,16 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET - repository_id = ?, executor_type = ?, executor_id = ?, executor_profile_id = ?, + executor_type = ?, executor_id = ?, executor_profile_id = ?, control_port = ?, status = ?, - worktree_id = ?, worktree_path = ?, worktree_branch = ?, workspace_path = ?, + workspace_path = ?, container_id = ?, sandbox_id = ?, task_dir_name = ?, updated_at = ? WHERE id = ? `), - env.RepositoryID, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, + env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(env.Status), - env.WorktreeID, env.WorktreePath, env.WorktreeBranch, env.WorkspacePath, + env.WorkspacePath, env.ContainerID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, @@ -211,7 +218,9 @@ func (r *Repository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID st return err } -// CreateTaskEnvironmentRepo inserts a single per-repo environment row. +// CreateTaskEnvironmentRepo inserts a single per-repo environment row. The +// owning task is resolved through the environment and serialized against the +// task cleanup barrier before the insert commits. func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { if repo.TaskEnvironmentID == "" { return fmt.Errorf("task_environment_id is required") @@ -219,29 +228,49 @@ func (r *Repository) CreateTaskEnvironmentRepo(ctx context.Context, repo *models if repo.RepositoryID == "" { return fmt.Errorf("repository_id is required") } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var taskID string + if err := tx.QueryRowContext(ctx, r.db.Rebind(` + SELECT task_id FROM task_environments WHERE id = ?`), repo.TaskEnvironmentID).Scan(&taskID); err != nil { + return fmt.Errorf("resolve environment owner %s: %w", repo.TaskEnvironmentID, err) + } + if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { + return err + } + if repo.ID == "" { repo.ID = uuid.New().String() } now := time.Now().UTC() repo.CreatedAt = now repo.UpdatedAt = now + if repo.Status == "" { + repo.Status = worktreeRepoStatusActive + } - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environment_repos ( id, task_environment_id, repository_id, branch_slug, worktree_id, worktree_path, worktree_branch, - position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + position, error_message, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, - repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, - ) - return err + repo.Position, repo.ErrorMessage, repo.Status, repo.CreatedAt, repo.UpdatedAt, + ); err != nil { + return err + } + return tx.Commit() } // insertTaskEnvironmentRepoTx is the in-transaction variant of CreateTaskEnvironmentRepo. -func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx, repo *models.TaskEnvironmentRepo) error { +func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sqlx.Tx, repo *models.TaskEnvironmentRepo) error { if repo.RepositoryID == "" { return fmt.Errorf("repository_id is required") } @@ -251,17 +280,20 @@ func (r *Repository) insertTaskEnvironmentRepoTx(ctx context.Context, tx *sql.Tx now := time.Now().UTC() repo.CreatedAt = now repo.UpdatedAt = now + if repo.Status == "" { + repo.Status = worktreeRepoStatusActive + } _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environment_repos ( id, task_environment_id, repository_id, branch_slug, worktree_id, worktree_path, worktree_branch, - position, error_message, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + position, error_message, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, - repo.Position, repo.ErrorMessage, repo.CreatedAt, repo.UpdatedAt, + repo.Position, repo.ErrorMessage, repo.Status, repo.CreatedAt, repo.UpdatedAt, ) return err } @@ -272,7 +304,8 @@ func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) SELECT id, task_environment_id, repository_id, COALESCE(branch_slug, ''), worktree_id, worktree_path, worktree_branch, - position, error_message, created_at, updated_at + position, error_message, COALESCE(status, ''), + created_at, updated_at, merged_at, deleted_at FROM task_environment_repos WHERE task_environment_id = ? ORDER BY position ASC, created_at ASC @@ -285,14 +318,24 @@ func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) var out []*models.TaskEnvironmentRepo for rows.Next() { repo := &models.TaskEnvironmentRepo{} + var mergedAt, deletedAt sql.NullTime if err := rows.Scan( &repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID, &repo.BranchSlug, &repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch, - &repo.Position, &repo.ErrorMessage, &repo.CreatedAt, &repo.UpdatedAt, + &repo.Position, &repo.ErrorMessage, &repo.Status, + &repo.CreatedAt, &repo.UpdatedAt, &mergedAt, &deletedAt, ); err != nil { return nil, err } + if mergedAt.Valid { + t := mergedAt.Time + repo.MergedAt = &t + } + if deletedAt.Valid { + t := deletedAt.Time + repo.DeletedAt = &t + } out = append(out, repo) } return out, rows.Err() @@ -306,11 +349,13 @@ func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models UPDATE task_environment_repos SET branch_slug = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, - position = ?, error_message = ?, updated_at = ? + position = ?, error_message = ?, status = ?, + merged_at = ?, deleted_at = ?, updated_at = ? WHERE id = ? `), repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, - repo.Position, repo.ErrorMessage, repo.UpdatedAt, + repo.Position, repo.ErrorMessage, repo.Status, + repo.MergedAt, repo.DeletedAt, repo.UpdatedAt, repo.ID, ) if err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/workspace_test.go b/apps/backend/internal/task/repository/sqlite/workspace_test.go index deaa525115..67aaaed544 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_test.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_test.go @@ -252,11 +252,8 @@ func seedWorkspaceCascadeRows(t *testing.T, repo *Repository, workspaceID string if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ ID: "env-delete", TaskID: "task-delete", - RepositoryID: "repo-delete", ExecutorType: string(models.ExecutorTypeWorktree), Status: models.TaskEnvironmentStatusReady, - WorktreeID: "wt-delete", - WorktreePath: "/tmp/wt-delete", WorkspacePath: "/tmp/wt-delete", Repos: []*models.TaskEnvironmentRepo{{ ID: "env-repo-delete", @@ -277,16 +274,6 @@ func seedWorkspaceCascadeRows(t *testing.T, repo *Repository, workspaceID string }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "session-wt-delete", - SessionID: "session-delete", - WorktreeID: "wt-delete", - RepositoryID: "repo-delete", - WorktreePath: "/tmp/wt-delete", - WorktreeBranch: "branch-delete", - }); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) - } if err := repo.CreateTurn(ctx, &models.Turn{ ID: "turn-delete", TaskSessionID: "session-delete", @@ -334,7 +321,6 @@ func assertNoWorkspaceCascadeDependents(t *testing.T, repo *Repository) { "task_sessions", "task_environments", "task_environment_repos", - "task_session_worktrees", "task_session_turns", "task_session_messages", "task_plans", diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go new file mode 100644 index 0000000000..c67ab09260 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go @@ -0,0 +1,238 @@ +package sqlite + +import ( + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" +) + +// The one-time task-worktree ownership cutover (ADR-2026-08-08): backfill +// task_environment_repos from the legacy flat task_environments worktree +// columns and the task_session_worktrees table, validate the complete +// worktree inventory, then drop both legacy artifacts in the same +// transaction. Runtime code must never reference the legacy schema; only this +// migration knows it. +// +// This migration returns every error directly. It never uses the +// best-effort MigrateLogger path, whose contract swallows unexpected +// failures. + +const ( + executorTypeLocalPC = "local_pc" + taskEnvironmentStatusStopped = "stopped" +) + +// taskWorktreeCutoverLockID serializes concurrent PostgreSQL initializers. +// All instances must derive the same bigint so a second boot waits for the +// first to finish the cutover instead of racing it. +const taskWorktreeCutoverLockID int64 = 0x4B44_5457_4354 // "KDTWC" marker + +// finalTaskEnvironmentsDDL is the task_environments shape without the +// deprecated flat repository_id/worktree_id/worktree_path/worktree_branch +// columns. tableName must be a literal table name (shadow tables reuse it). +func finalTaskEnvironmentsDDL(tableName string) string { + return fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + executor_type TEXT NOT NULL DEFAULT '', + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + control_port INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'creating', + workspace_path TEXT DEFAULT '', + container_id TEXT DEFAULT '', + sandbox_id TEXT DEFAULT '', + task_dir_name TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + )`, tableName) +} + +// finalTaskEnvironmentReposDDL is the task_environment_repos shape with the +// physical-worktree lifecycle columns (status, merged_at, deleted_at). +// refTable is the environment table the FK targets (the shadow name during +// the cutover). SQLite rewrites FK references when the parent table is +// renamed; PostgreSQL binds by OID, so the shadow constraint keeps its +// shadow-scoped name and is renamed after the swap. +func finalTaskEnvironmentReposDDL(tableName, refTable string) string { + return fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP, + FOREIGN KEY (task_environment_id) REFERENCES %s(id) ON DELETE CASCADE, + UNIQUE(task_environment_id, repository_id, branch_slug) + )`, tableName, refTable) +} + +// normalizeTaskWorktreeOwnership performs the one-time cutover to the +// task-owned worktree model. It is a no-op on fresh databases and on +// databases that already completed the cutover (no task_session_worktrees +// table). On legacy databases it backfills task_environment_repos from every +// legacy source, links sessions to their task environment, validates the +// complete worktree inventory, drops task_session_worktrees and the +// deprecated task_environments columns, and purges preview-build +// session_delete cleanup jobs — all in one transaction. +func (r *Repository) normalizeTaskWorktreeOwnership() error { + exists, err := r.tableExists("task_session_worktrees") + if err != nil { + return fmt.Errorf("cutover: probe legacy table: %w", err) + } + if !exists { + return nil + } + + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("cutover: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if err := r.cutoverAcquireLocks(tx); err != nil { + return err + } + + if err := r.cutoverEnsureLegacyBranchSlug(tx); err != nil { + return err + } + cut := &worktreeCutover{ + envs: make(map[string]*legacyEnv), + taskEnvs: make(map[string][]*legacyEnv), + sessions: make(map[string]*legacySession), + sessionTasks: make(map[string]string), + sessionEnvIDs: make(map[string]string), + tasks: make(map[string]*taskWorktreeTargets), + taskEnvIDs: make(map[string]string), + loserEnvIDs: make(map[string]bool), + executorTypes: make(map[string]string), + } + if err := cut.loadLegacy(tx); err != nil { + return err + } + if err := cut.normalize(tx); err != nil { + return err + } + if err := r.cutoverBuildShadows(tx); err != nil { + return err + } + if err := r.insertNormalized(cut, tx); err != nil { + return err + } + if err := r.validateCutover(cut, tx); err != nil { + return err + } + if err := r.maybeFailCutover("pre_swap"); err != nil { + return err + } + if err := r.cutoverSwap(tx); err != nil { + return err + } + if err := r.maybeFailCutover("post_swap"); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("cutover: commit: %w", err) + } + return nil +} + +// cutoverAcquireLocks serializes the cutover for the active database engine: +// PostgreSQL takes a migration advisory lock plus exclusive locks on every +// affected ownership table (aborting on lock timeout); SQLite relies on the +// transaction becoming the writer lock. FK enforcement stays ON — the swap +// drops task_environment_repos before task_environments, so no DROP violates +// a foreign key (PRAGMA foreign_keys would be a no-op inside the transaction +// anyway). +func (r *Repository) cutoverAcquireLocks(tx *sqlx.Tx) error { + if !dialect.IsPostgres(r.db.DriverName()) { + return nil + } + if _, err := tx.Exec(`SET LOCAL lock_timeout = '30s'`); err != nil { + return fmt.Errorf("cutover: set lock timeout: %w", err) + } + if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, taskWorktreeCutoverLockID); err != nil { + return fmt.Errorf("cutover: acquire migration advisory lock: %w", err) + } + if _, err := tx.Exec(` + LOCK TABLE task_session_worktrees, task_environments, + task_environment_repos, task_sessions, task_resource_cleanup_jobs + IN ACCESS EXCLUSIVE MODE`); err != nil { + return fmt.Errorf("cutover: lock ownership tables: %w", err) + } + return nil +} + +// tableExists reports whether a table exists on either database engine. +func (r *Repository) tableExists(name string) (bool, error) { + var exists bool + var err error + if dialect.IsPostgres(r.db.DriverName()) { + err = r.db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = $1 AND table_schema = current_schema())`, + name).Scan(&exists) + } else { + err = r.db.QueryRow( + `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)`, + name).Scan(&exists) + } + if err != nil { + return false, fmt.Errorf("probe table %s: %w", name, err) + } + return exists, nil +} + +// cutoverEnsureLegacyBranchSlug adds task_session_worktrees.branch_slug when +// a very old database predates the multi-branch column. The legacy migration +// that used to do this (via MigrateLogger) was removed with the legacy +// schema; the cutover must ensure the column before reading it. +func (r *Repository) cutoverEnsureLegacyBranchSlug(tx *sqlx.Tx) error { + has, err := r.columnExists(tx, "task_session_worktrees", "branch_slug") + if err != nil { + return err + } + if has { + return nil + } + if _, err := tx.Exec(`ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("cutover: add legacy branch_slug: %w", err) + } + return nil +} + +// columnExists reports whether a column exists on either database engine. +func (r *Repository) columnExists(tx *sqlx.Tx, table, column string) (bool, error) { + var exists bool + var err error + if dialect.IsPostgres(r.db.DriverName()) { + err = tx.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 AND table_schema = current_schema())`, + table, column).Scan(&exists) + } else { + err = tx.QueryRow(`SELECT EXISTS ( + SELECT 1 FROM pragma_table_info(?) WHERE name = ?)`, + table, column).Scan(&exists) + } + if err != nil { + return false, fmt.Errorf("probe column %s.%s: %w", table, column, err) + } + return exists, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go new file mode 100644 index 0000000000..b7f9a0daad --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go @@ -0,0 +1,757 @@ +package sqlite + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/testutil" +) + +// --------------------------------------------------------------------------- +// Seed helpers: build a legacy pre-cutover database by pre-creating the +// legacy-shaped task_environments and task_session_worktrees tables before +// the repository initializer runs (which would otherwise create the final +// shapes). +// --------------------------------------------------------------------------- + +const legacyEnvDDL = ` + CREATE TABLE task_environments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + repository_id TEXT DEFAULT '', + executor_type TEXT NOT NULL DEFAULT '', + executor_id TEXT DEFAULT '', + executor_profile_id TEXT DEFAULT '', + agent_execution_id TEXT DEFAULT '', + control_port INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'creating', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + workspace_path TEXT DEFAULT '', + container_id TEXT DEFAULT '', + sandbox_id TEXT DEFAULT '', + task_dir_name TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE INDEX idx_task_environments_task_id ON task_environments(task_id); + CREATE TABLE task_environment_repos ( + id TEXT PRIMARY KEY, + task_environment_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', + worktree_id TEXT DEFAULT '', + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + position INTEGER DEFAULT 0, + error_message TEXT DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE INDEX idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id);` + +const legacySessionWorktreeDDL = ` + CREATE TABLE task_session_worktrees ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + worktree_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + branch_slug TEXT NOT NULL DEFAULT '', + position INTEGER DEFAULT 0, + worktree_path TEXT DEFAULT '', + worktree_branch TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + merged_at TIMESTAMP, + deleted_at TIMESTAMP + ); + CREATE INDEX idx_task_session_worktrees_session_id ON task_session_worktrees(session_id);` + +// openLegacyDB opens a SQLite database in the pre-cutover schema: it runs +// the current initializer once (final shape for everything else), then +// rewinds task_environments and task_environment_repos to the legacy shape +// and recreates task_session_worktrees. +func openLegacyDB(t *testing.T) *sqlx.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "legacy.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + if _, err := NewWithDB(sqlxDB, sqlxDB, nil); err != nil { + t.Fatalf("seed baseline schema: %v", err) + } + rewindToLegacySchema(t, sqlxDB) + return sqlxDB +} + +func rewindToLegacySchema(t *testing.T, db *sqlx.DB) { + t.Helper() + if _, err := db.Exec(`DROP TABLE task_environment_repos`); err != nil { + t.Fatalf("drop final env repos: %v", err) + } + if _, err := db.Exec(`DROP TABLE task_environments`); err != nil { + t.Fatalf("drop final envs: %v", err) + } + for _, ddl := range []string{legacyEnvDDL, legacySessionWorktreeDDL} { + if _, err := db.Exec(ddl); err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + } +} + +type legacySeed struct { + envID, taskID, repoID, sessionID string +} + +func seedLegacyTask(t *testing.T, db *sqlx.DB, seed legacySeed, startedAt time.Time) { + t.Helper() + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, 'ws-1', 'legacy task', ?, ?)`), seed.taskID, startedAt, startedAt); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, ?, 'COMPLETED', ?, ?)`), seed.sessionID, seed.taskID, startedAt, startedAt); err != nil { + t.Fatalf("seed session: %v", err) + } +} + +func seedLegacySessionWorktree(t *testing.T, db *sqlx.DB, sessionID, worktreeID, repoID, slug, path, branch, status string, startedAt time.Time) { + t.Helper() + var deletedAt interface{} + if status == "deleted" { + deletedAt = startedAt + } + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_session_worktrees ( + id, session_id, worktree_id, repository_id, branch_slug, position, + worktree_path, worktree_branch, status, created_at, updated_at, merged_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL, ?)`), + worktreeID+"-row-"+sessionID, sessionID, worktreeID, repoID, slug, path, branch, status, startedAt, startedAt, deletedAt); err != nil { + t.Fatalf("seed session worktree: %v", err) + } +} + +func seedLegacyFlatEnv(t *testing.T, db *sqlx.DB, seed legacySeed, worktreeID, path, branch string, startedAt time.Time) { + t.Helper() + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_environments ( + id, task_id, repository_id, executor_type, executor_id, executor_profile_id, + control_port, status, worktree_id, worktree_path, worktree_branch, workspace_path, + container_id, sandbox_id, task_dir_name, created_at, updated_at + ) VALUES (?, ?, ?, 'worktree', 'exec-1', '', 0, 'ready', ?, ?, ?, ?, '', '', '', ?, ?)`), + seed.envID, seed.taskID, seed.repoID, worktreeID, path, branch, path, startedAt, startedAt); err != nil { + t.Fatalf("seed flat env: %v", err) + } +} + +func seedCleanupJob(t *testing.T, db *sqlx.DB, id, taskID, trigger, state string, startedAt time.Time) { + t.Helper() + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_resource_cleanup_jobs ( + id, operation_id, task_id, trigger, state, resource_snapshot, attempts, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, '{}', 0, ?, ?)`), + id, id, taskID, trigger, state, startedAt, startedAt); err != nil { + t.Fatalf("seed cleanup job: %v", err) + } +} + +func legacyTableExists(t *testing.T, db *sqlx.DB, table string) bool { + t.Helper() + var exists bool + err := db.QueryRow( + `SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)`, table).Scan(&exists) + if err != nil { + t.Fatalf("probe table: %v", err) + } + return exists +} + +// --------------------------------------------------------------------------- +// Fresh-schema shape +// --------------------------------------------------------------------------- + +func TestCutover_FreshSchemaHasFinalShape(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "fresh.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + + if _, err := NewWithDB(sqlxDB, sqlxDB, nil); err != nil { + t.Fatalf("init fresh schema: %v", err) + } + if legacyTableExists(t, sqlxDB, "task_session_worktrees") { + t.Fatal("fresh database must not contain task_session_worktrees") + } + + envCols := tableColumnSet(t, sqlxDB, "task_environments") + for _, gone := range []string{"repository_id", "worktree_id", "worktree_path", "worktree_branch"} { + if envCols[gone] { + t.Fatalf("fresh task_environments still has legacy column %s", gone) + } + } + repoCols := tableColumnSet(t, sqlxDB, "task_environment_repos") + for _, required := range []string{"status", "merged_at", "deleted_at"} { + if !repoCols[required] { + t.Fatalf("fresh task_environment_repos missing %s", required) + } + } +} + +func tableColumnSet(t *testing.T, db *sqlx.DB, table string) map[string]bool { + t.Helper() + cols := map[string]bool{} + rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table) + if err != nil { + t.Fatalf("pragma table_info: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("scan column: %v", err) + } + cols[name] = true + } + return cols +} + +// --------------------------------------------------------------------------- +// Legacy normalization +// --------------------------------------------------------------------------- + +// TestCutover_NormalizesLegacyFlatEnvironment seeds a legacy flat +// single-repo environment and proves it lands as an environment-repository +// row while the legacy table and flat columns disappear. +func TestCutover_NormalizesLegacyFlatEnvironment(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-1", taskID: "task-1", repoID: "repo-1", sessionID: "sess-1"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-1", "wt-1", "repo-1", "", "/tasks/t-1/kandev", "feature/x", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-1", "/tasks/t-1/kandev", "feature/x", now) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("cutover: %v", err) + } + ctx := context.Background() + + if legacyTableExists(t, db, "task_session_worktrees") { + t.Fatal("task_session_worktrees must be dropped after cutover") + } + env, err := repo.GetTaskEnvironment(ctx, "env-1") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if len(env.Repos) != 1 { + t.Fatalf("env repos = %d, want 1", len(env.Repos)) + } + row := env.Repos[0] + if row.RepositoryID != "repo-1" || row.WorktreeID != "wt-1" || + row.WorktreePath != "/tasks/t-1/kandev" || row.WorktreeBranch != "feature/x" { + t.Fatalf("normalized repo row = %+v", row) + } + if row.Status != "active" { + t.Fatalf("repo status = %q, want active", row.Status) + } + session, err := repo.GetTaskSession(ctx, "sess-1") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if session.TaskEnvironmentID != "env-1" { + t.Fatalf("session env = %q, want env-1", session.TaskEnvironmentID) + } +} + +// TestCutover_NormalizesSessionOnlyOwnership seeds sessions with worktrees +// but no environment, and proves the cutover creates one task-owned +// environment, homes the worktrees, and links the sessions. +func TestCutover_NormalizesSessionOnlyOwnership(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{taskID: "task-2", sessionID: "sess-2"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-2", "wt-2", "repo-2", "", "/tasks/t-2/kandev", "feature/y", "active", now) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("cutover: %v", err) + } + ctx := context.Background() + + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-2") + if err != nil { + t.Fatalf("GetTaskEnvironmentByTaskID: %v", err) + } + if env == nil { + t.Fatal("expected a normalized task-owned environment") + } + if env.ExecutorType != "local_pc" { + t.Fatalf("backfilled executor_type = %q, want default local_pc", env.ExecutorType) + } + if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-2" { + t.Fatalf("normalized repos = %+v, want wt-2", env.Repos) + } + session, err := repo.GetTaskSession(ctx, "sess-2") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if session.TaskEnvironmentID != env.ID { + t.Fatalf("session env = %q, want %q", session.TaskEnvironmentID, env.ID) + } +} + +// TestCutover_PreservesEnvRepoRowsAndSharedSessions seeds an existing +// task_environment_repos row, a session worktree referencing the same +// worktree, and a second session sharing the environment. The cutover must +// keep one canonical row and link both sessions. +func TestCutover_PreservesEnvRepoRowsAndSharedSessions(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + if _, err := db.Exec(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES ('task-3', 'ws-1', 'legacy', ?, ?)`, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + for _, s := range []string{"sess-3a", "sess-3b"} { + if _, err := db.Exec(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, 'task-3', 'COMPLETED', ?, ?)`, s, now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + } + seedLegacySessionWorktree(t, db, "sess-3a", "wt-3", "repo-3", "", "/tasks/t-3/kandev", "feature/z", "active", now) + seedLegacySessionWorktree(t, db, "sess-3b", "wt-3", "repo-3", "", "/tasks/t-3/kandev", "feature/z", "active", now) + seedLegacyFlatEnv(t, db, legacySeed{envID: "env-3", taskID: "task-3", repoID: "repo-3", sessionID: "sess-3a"}, "wt-3", "/tasks/t-3/kandev", "feature/z", now) + if _, err := db.Exec(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, branch_slug, + worktree_id, worktree_path, worktree_branch, position, error_message, created_at, updated_at + ) VALUES ('env-repo-3', 'env-3', 'repo-3', '', 'wt-3', '/tasks/t-3/kandev', 'feature/z', 0, '', ?, ?)`, + now, now); err != nil { + t.Fatalf("seed env repo: %v", err) + } + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("cutover: %v", err) + } + ctx := context.Background() + + env, err := repo.GetTaskEnvironment(ctx, "env-3") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-3" { + t.Fatalf("env repos = %+v, want one wt-3 row", env.Repos) + } + for _, s := range []string{"sess-3a", "sess-3b"} { + session, err := repo.GetTaskSession(ctx, s) + if err != nil { + t.Fatalf("GetTaskSession(%s): %v", s, err) + } + if session.TaskEnvironmentID != "env-3" { + t.Fatalf("session %s env = %q, want env-3", s, session.TaskEnvironmentID) + } + } +} + +// TestCutover_ZeroSessionEnvironmentIsRetained proves a task-owned +// environment with no sessions survives the cutover untouched. +func TestCutover_ZeroSessionEnvironmentIsRetained(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + if _, err := db.Exec(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES ('task-4', 'ws-1', 'legacy', ?, ?)`, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + seedLegacyFlatEnv(t, db, legacySeed{envID: "env-4", taskID: "task-4", repoID: "repo-4", sessionID: ""}, "wt-4", "/tasks/t-4/kandev", "feature/w", now) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("cutover: %v", err) + } + ctx := context.Background() + + env, err := repo.GetTaskEnvironment(ctx, "env-4") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-4" { + t.Fatalf("zero-session env repos = %+v, want wt-4", env.Repos) + } +} + +// TestCutover_ReplayIsNoOp proves a second boot (already-normalized +// database) skips the cutover without errors. +func TestCutover_ReplayIsNoOp(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-5", taskID: "task-5", repoID: "repo-5", sessionID: "sess-5"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-5", "wt-5", "repo-5", "", "/tasks/t-5/kandev", "feature/v", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-5", "/tasks/t-5/kandev", "feature/v", now) + + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("first boot: %v", err) + } + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("second boot (replay): %v", err) + } +} + +// TestCutover_PurgesPreviewCleanupJobs removes preview-build session_delete +// cleanup jobs while preserving task-lifecycle jobs. +func TestCutover_PurgesPreviewCleanupJobs(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-6", taskID: "task-6", repoID: "repo-6", sessionID: "sess-6"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-6", "wt-6", "repo-6", "", "/tasks/t-6/kandev", "feature/u", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-6", "/tasks/t-6/kandev", "feature/u", now) + seedCleanupJob(t, db, "job-session-delete", "task-6", "session_delete", "pending", now) + seedCleanupJob(t, db, "job-archive", "task-6", "archive", "pending", now) + seedCleanupJob(t, db, "job-archive-done", "task-6", "archive", "succeeded", now) + + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("cutover: %v", err) + } + + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM task_resource_cleanup_jobs`).Scan(&count); err != nil { + t.Fatalf("count jobs: %v", err) + } + if count != 2 { + t.Fatalf("cleanup jobs after cutover = %d, want 2 (task-lifecycle jobs preserved)", count) + } +} + +// TestCutover_ConflictingWorktreesFailClosed proves that two sessions of the +// same task with different worktrees for the same repository slot abort the +// cutover and leave the legacy database intact. +func TestCutover_ConflictingWorktreesFailClosed(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + if _, err := db.Exec(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES ('task-conflict', 'ws-1', 'legacy', ?, ?)`, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + for _, s := range []string{"sess-c1", "sess-c2"} { + if _, err := db.Exec(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, 'task-conflict', 'COMPLETED', ?, ?)`, s, now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + } + seedLegacySessionWorktree(t, db, "sess-c1", "wt-c1", "repo-c", "", "/t/c1", "feature/c1", "active", now) + seedLegacySessionWorktree(t, db, "sess-c2", "wt-c2", "repo-c", "", "/t/c2", "feature/c2", "active", now) + + _, err := NewWithDB(db, db, nil) + if err == nil { + t.Fatal("expected cutover to fail on conflicting worktree identities") + } + if !strings.Contains(err.Error(), "conflict") { + t.Fatalf("error must describe the conflict, got: %v", err) + } + // Pre-upgrade state must be intact. + if !legacyTableExists(t, db, "task_session_worktrees") { + t.Fatal("legacy table must survive a failed cutover") + } + envCols := tableColumnSet(t, db, "task_environments") + if !envCols["worktree_id"] || !envCols["repository_id"] { + t.Fatal("legacy flat columns must survive a failed cutover") + } +} + +// TestCutover_RollbackAtEveryFailpoint injects a failure at each cutover +// step and proves the transaction restores the complete legacy schema and +// data. +func TestCutover_RollbackAtEveryFailpoint(t *testing.T) { + for _, step := range []string{ + "create_shadow", "copy_envs", "backfill_repos", "link_sessions", + "validate", "pre_swap", "drop_legacy", "swap", "post_swap", + } { + t.Run(step, func(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-r", taskID: "task-r", repoID: "repo-r", sessionID: "sess-r"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-r", "wt-r", "repo-r", "", "/tasks/t-r/kandev", "feature/r", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-r", "/tasks/t-r/kandev", "feature/r", now) + seedCleanupJob(t, db, "job-archive-r", "task-r", "archive", "pending", now) + + repo := &Repository{db: db, ro: db, migrate: dbutil.NewMigrateLogger(db, nil), failCutoverAfter: step} + err := repo.initSchema() + if err == nil { + t.Fatalf("expected injected failure at %s", step) + } + if !legacyTableExists(t, db, "task_session_worktrees") { + t.Fatalf("task_session_worktrees must survive rollback at %s", step) + } + var path, branch string + if err := db.QueryRow(` + SELECT worktree_path, worktree_branch FROM task_session_worktrees WHERE worktree_id = 'wt-r'`, + ).Scan(&path, &branch); err != nil { + t.Fatalf("legacy worktree row lost after rollback at %s: %v", step, err) + } + if path != "/tasks/t-r/kandev" || branch != "feature/r" { + t.Fatalf("legacy worktree data changed after rollback at %s", step) + } + envCols := tableColumnSet(t, db, "task_environments") + if !envCols["worktree_id"] { + t.Fatalf("flat env columns lost after rollback at %s", step) + } + var jobCount int + if err := db.QueryRow( + `SELECT COUNT(*) FROM task_resource_cleanup_jobs WHERE trigger = 'archive'`).Scan(&jobCount); err != nil { + t.Fatalf("count jobs after rollback at %s: %v", step, err) + } + if jobCount != 1 { + t.Fatalf("cleanup job lost after rollback at %s", step) + } + }) + } +} + +// TestCutover_AmbiguousPathMismatchFailsClosed proves that two sources +// disagreeing on a worktree's path abort the cutover with a diagnostic. +func TestCutover_AmbiguousPathMismatchFailsClosed(t *testing.T) { + db := openLegacyDB(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-p", taskID: "task-p", repoID: "repo-p", sessionID: "sess-p"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-p", "wt-p", "repo-p", "", "/t/one", "feature/p", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-p", "/t/two", "feature/p", now) + + _, err := NewWithDB(db, db, nil) + if err == nil { + t.Fatal("expected cutover to fail on path mismatch") + } +} + +// --------------------------------------------------------------------------- +// PostgreSQL coverage +// --------------------------------------------------------------------------- + +func openLegacyPostgres(t *testing.T) *sqlx.DB { + t.Helper() + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("seed baseline postgres schema: %v", err) + } + rewindToLegacySchema(t, db) + return db +} + +func TestCutoverPostgres_NormalizesLegacyFlatEnvironment(t *testing.T) { + db := openLegacyPostgres(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-pg1", taskID: "task-pg1", repoID: "repo-pg1", sessionID: "sess-pg1"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-pg1", "wt-pg1", "repo-pg1", "", "/tasks/pg1/kandev", "feature/pg", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-pg1", "/tasks/pg1/kandev", "feature/pg", now) + seedCleanupJob(t, db, "job-pg-session-delete", "task-pg1", "session_delete", "pending", now) + seedCleanupJob(t, db, "job-pg-archive", "task-pg1", "archive", "pending", now) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("postgres cutover: %v", err) + } + env, err := repo.GetTaskEnvironment(ctx, "env-pg1") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg1" || env.Repos[0].Status != "active" { + t.Fatalf("normalized repos = %+v", env.Repos) + } + session, err := repo.GetTaskSession(ctx, "sess-pg1") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + if session.TaskEnvironmentID != "env-pg1" { + t.Fatalf("session env = %q, want env-pg1", session.TaskEnvironmentID) + } + var tableCount int + if err := db.Get(&tableCount, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil { + t.Fatalf("count legacy table: %v", err) + } + if tableCount != 0 { + t.Fatal("task_session_worktrees must be dropped on postgres") + } + var jobCount int + if err := db.Get(&jobCount, `SELECT COUNT(*) FROM task_resource_cleanup_jobs`); err != nil { + t.Fatalf("count jobs: %v", err) + } + if jobCount != 1 { + t.Fatalf("postgres cleanup jobs after cutover = %d, want 1", jobCount) + } +} + +func TestCutoverPostgres_SessionOnlyNormalization(t *testing.T) { + db := openLegacyPostgres(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{taskID: "task-pg2", sessionID: "sess-pg2"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-pg2", "wt-pg2", "repo-pg2", "", "/tasks/pg2/kandev", "feature/pg2", "active", now) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("postgres cutover: %v", err) + } + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-pg2") + if err != nil { + t.Fatalf("GetTaskEnvironmentByTaskID: %v", err) + } + if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg2" { + t.Fatalf("normalized env = %+v", env) + } +} + +func TestCutoverPostgres_ConflictingWorktreesFailClosed(t *testing.T) { + db := openLegacyPostgres(t) + now := time.Now().UTC().Truncate(time.Second) + if _, err := db.Exec(db.Rebind(` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, 'ws-1', 'legacy', ?, ?)`), "task-pg-conflict", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + for _, s := range []string{"sess-pgc1", "sess-pgc2"} { + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) + VALUES (?, 'task-pg-conflict', 'COMPLETED', ?, ?)`), s, now, now); err != nil { + t.Fatalf("seed session: %v", err) + } + } + seedLegacySessionWorktree(t, db, "sess-pgc1", "wt-pgc1", "repo-pgc", "", "/t/pgc1", "feature/a", "active", now) + seedLegacySessionWorktree(t, db, "sess-pgc2", "wt-pgc2", "repo-pgc", "", "/t/pgc2", "feature/b", "active", now) + + _, err := NewWithDB(db, db, nil) + if err == nil { + t.Fatal("expected postgres cutover to fail on conflicting worktrees") + } + var tableCount int + if err := db.Get(&tableCount, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil { + t.Fatalf("count legacy table: %v", err) + } + if tableCount != 1 { + t.Fatal("failed postgres cutover must leave the legacy table intact") + } +} + +// TestCutoverPostgres_ReplayIsNoOp proves a second boot skips the cutover. +func TestCutoverPostgres_ReplayIsNoOp(t *testing.T) { + db := openLegacyPostgres(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-pg3", taskID: "task-pg3", repoID: "repo-pg3", sessionID: "sess-pg3"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-pg3", "wt-pg3", "repo-pg3", "", "/tasks/pg3/kandev", "feature/pg3", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-pg3", "/tasks/pg3/kandev", "feature/pg3", now) + + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("first boot: %v", err) + } + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("second boot (replay): %v", err) + } +} + +// TestCutoverPostgres_RollbackAtEveryFailpoint mirrors the SQLite failpoint +// matrix on PostgreSQL. +func TestCutoverPostgres_RollbackAtEveryFailpoint(t *testing.T) { + for _, step := range []string{ + "create_shadow", "copy_envs", "backfill_repos", "link_sessions", + "validate", "pre_swap", "drop_legacy", "swap", "post_swap", + } { + t.Run(step, func(t *testing.T) { + db := openLegacyPostgres(t) + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{envID: "env-pgr", taskID: "task-pgr", repoID: "repo-pgr", sessionID: "sess-pgr"} + seedLegacyTask(t, db, seed, now) + seedLegacySessionWorktree(t, db, "sess-pgr", "wt-pgr", "repo-pgr", "", "/tasks/pgr/kandev", "feature/pgr", "active", now) + seedLegacyFlatEnv(t, db, seed, "wt-pgr", "/tasks/pgr/kandev", "feature/pgr", now) + + repo := &Repository{db: db, ro: db, migrate: dbutil.NewMigrateLogger(db, nil), failCutoverAfter: step} + err := repo.initSchema() + if err == nil { + t.Fatalf("expected injected failure at %s", step) + } + var tableCount int + if err := db.Get(&tableCount, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil { + t.Fatalf("count legacy table: %v", err) + } + if tableCount != 1 { + t.Fatalf("legacy table must survive rollback at %s", step) + } + var path string + if err := db.Get(&path, db.Rebind(` + SELECT worktree_path FROM task_session_worktrees WHERE worktree_id = ?`), "wt-pgr"); err != nil { + t.Fatalf("legacy worktree row lost after rollback at %s: %v", step, err) + } + if path != "/tasks/pgr/kandev" { + t.Fatalf("legacy worktree data changed after rollback at %s", step) + } + }) + } +} + +// TestCutoverPostgres_FreshSchemaIsFinal proves a fresh PostgreSQL database +// contains only the final schema. +func TestCutoverPostgres_FreshSchemaIsFinal(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("init fresh postgres schema: %v", err) + } + var tableCount int + if err := db.Get(&tableCount, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil { + t.Fatalf("count legacy table: %v", err) + } + if tableCount != 0 { + t.Fatal("fresh postgres database must not contain task_session_worktrees") + } + // The environment FK must reference tasks and the repos FK must + // reference task_environments after the swap. + var fkCount int + if err := db.Get(&fkCount, ` + SELECT COUNT(*) FROM information_schema.table_constraints tc + JOIN information_schema.referential_constraints rc + ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.constraint_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = rc.unique_constraint_name AND ccu.constraint_schema = rc.unique_constraint_schema + WHERE tc.table_name = 'task_environment_repos' + AND tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = 'task_environments' + AND tc.table_schema = current_schema()`); err != nil { + t.Fatalf("count env-repo FK: %v", err) + } + if fkCount != 1 { + t.Fatalf("task_environment_repos FK to task_environments missing: %d", fkCount) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go new file mode 100644 index 0000000000..4374e0272b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go @@ -0,0 +1,459 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// worktreeCutover holds the legacy inventory and the normalized result. +type worktreeCutover struct { + envs map[string]*legacyEnv + taskEnvs map[string][]*legacyEnv + sessions map[string]*legacySession + sessionTasks map[string]string + sessionEnvIDs map[string]string + envRepos []legacyEnvRepo + sessionWts []legacySessionWorktree + tasks map[string]*taskWorktreeTargets + taskEnvIDs map[string]string + loserEnvIDs map[string]bool + executorTypes map[string]string + conflicts []string +} + +type legacyEnv struct { + id, taskID, repositoryID, executorType, executorID, executorProfileID string + controlPort int + status, worktreeID, worktreePath, worktreeBranch, workspacePath string + containerID, sandboxID, taskDirName string + createdAt, updatedAt time.Time +} + +type legacySession struct { + id, taskID, taskEnvironmentID string + startedAt time.Time + executorID, executorProfileID string + repositoryID string + containerID string +} + +type legacyEnvRepo struct { + id, envID, repositoryID, branchSlug string + worktreeID, worktreePath, worktreeBranch string + position int + errorMessage string + createdAt, updatedAt time.Time +} + +type legacySessionWorktree struct { + sessionID, worktreeID, repositoryID, branchSlug string + position int + worktreePath, worktreeBranch, status string + createdAt, updatedAt time.Time + mergedAt, deletedAt *time.Time +} + +// loadLegacy reads every legacy ownership row into memory. The transaction +// holds the writer/advisory locks, so the snapshot is consistent. +func (c *worktreeCutover) loadLegacy(tx *sqlx.Tx) error { + if err := c.loadLegacyEnvs(tx); err != nil { + return err + } + if err := c.loadLegacySessions(tx); err != nil { + return err + } + if err := c.loadLegacyEnvRepos(tx); err != nil { + return err + } + return c.loadLegacySessionWorktrees(tx) +} + +func (c *worktreeCutover) loadLegacyEnvs(tx *sqlx.Tx) error { + rows, err := tx.Query(` + SELECT id, task_id, repository_id, executor_type, executor_id, + executor_profile_id, control_port, status, + COALESCE(worktree_id, ''), COALESCE(worktree_path, ''), + COALESCE(worktree_branch, ''), COALESCE(workspace_path, ''), + COALESCE(container_id, ''), COALESCE(sandbox_id, ''), + COALESCE(task_dir_name, ''), created_at, updated_at + FROM task_environments`) + if err != nil { + return fmt.Errorf("cutover: read legacy environments: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + env := &legacyEnv{} + if err := rows.Scan(&env.id, &env.taskID, &env.repositoryID, &env.executorType, + &env.executorID, &env.executorProfileID, &env.controlPort, &env.status, + &env.worktreeID, &env.worktreePath, &env.worktreeBranch, &env.workspacePath, + &env.containerID, &env.sandboxID, &env.taskDirName, &env.createdAt, &env.updatedAt); err != nil { + return fmt.Errorf("cutover: scan legacy environment: %w", err) + } + c.envs[env.id] = env + c.taskEnvs[env.taskID] = append(c.taskEnvs[env.taskID], env) + } + return rows.Err() +} + +func (c *worktreeCutover) loadLegacySessions(tx *sqlx.Tx) error { + // GROUP BY lists every non-aggregate column so PostgreSQL accepts the + // query (SQLite is lenient about functional dependency). started_at is + // scanned as a string because MIN() makes the driver return the raw + // TEXT value instead of a time. + rows, err := tx.Query(` + SELECT ts.id, ts.task_id, COALESCE(ts.task_environment_id, ''), + COALESCE(ts.executor_id, ''), COALESCE(ts.executor_profile_id, ''), + COALESCE(ts.repository_id, ''), COALESCE(er.container_id, ''), + MIN(ts.started_at) + FROM task_sessions ts + LEFT JOIN executors_running er ON er.session_id = ts.id + GROUP BY ts.id, ts.task_id, ts.task_environment_id, ts.executor_id, + ts.executor_profile_id, ts.repository_id, er.container_id`) + if err != nil { + return fmt.Errorf("cutover: read legacy sessions: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + s := &legacySession{} + var startedAt string + if err := rows.Scan(&s.id, &s.taskID, &s.taskEnvironmentID, &s.executorID, + &s.executorProfileID, &s.repositoryID, &s.containerID, &startedAt); err != nil { + return fmt.Errorf("cutover: scan legacy session: %w", err) + } + s.startedAt = parseLegacyTimestamp(startedAt) + c.sessions[s.id] = s + c.sessionTasks[s.id] = s.taskID + c.sessionEnvIDs[s.id] = s.taskEnvironmentID + } + return rows.Err() +} + +// parseLegacyTimestamp converts a stored timestamp text into a time.Time. +// SQLite returns raw TEXT for aggregate expressions; the application writes +// RFC3339Nano values. Unknown formats fall back to the zero time so the +// backfilled environment still carries a usable (if conservative) lifecycle. +func parseLegacyTimestamp(raw string) time.Time { + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05", + } { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +func (c *worktreeCutover) loadLegacyEnvRepos(tx *sqlx.Tx) error { + rows, err := tx.Query(` + SELECT id, task_environment_id, repository_id, COALESCE(branch_slug, ''), + COALESCE(worktree_id, ''), COALESCE(worktree_path, ''), + COALESCE(worktree_branch, ''), position, COALESCE(error_message, ''), + created_at, updated_at + FROM task_environment_repos`) + if err != nil { + return fmt.Errorf("cutover: read legacy environment repos: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var row legacyEnvRepo + if err := rows.Scan(&row.id, &row.envID, &row.repositoryID, &row.branchSlug, + &row.worktreeID, &row.worktreePath, &row.worktreeBranch, &row.position, + &row.errorMessage, &row.createdAt, &row.updatedAt); err != nil { + return fmt.Errorf("cutover: scan legacy environment repo: %w", err) + } + c.envRepos = append(c.envRepos, row) + } + return rows.Err() +} + +func (c *worktreeCutover) loadLegacySessionWorktrees(tx *sqlx.Tx) error { + rows, err := tx.Query(` + SELECT session_id, worktree_id, repository_id, COALESCE(branch_slug, ''), + position, COALESCE(worktree_path, ''), COALESCE(worktree_branch, ''), + status, created_at, updated_at, merged_at, deleted_at + FROM task_session_worktrees`) + if err != nil { + return fmt.Errorf("cutover: read legacy session worktrees: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var row legacySessionWorktree + var mergedAt, deletedAt sql.NullTime + if err := rows.Scan(&row.sessionID, &row.worktreeID, &row.repositoryID, &row.branchSlug, + &row.position, &row.worktreePath, &row.worktreeBranch, &row.status, + &row.createdAt, &row.updatedAt, &mergedAt, &deletedAt); err != nil { + return fmt.Errorf("cutover: scan legacy session worktree: %w", err) + } + if mergedAt.Valid { + t := mergedAt.Time + row.mergedAt = &t + } + if deletedAt.Valid { + t := deletedAt.Time + row.deletedAt = &t + } + if _, ok := c.sessions[row.sessionID]; !ok { + c.conflicts = append(c.conflicts, fmt.Sprintf( + "task_session_worktrees row %s references missing session %s", row.worktreeID, row.sessionID)) + continue + } + c.sessionWts = append(c.sessionWts, row) + } + return rows.Err() +} + +// taskWorktreeTargets accumulates the normalized environment-repository rows +// for one task. Keyed by repositoryID+"\x00"+branchSlug. +type taskWorktreeTargets struct { + envID string + byKey map[string]*envRepoTarget + byWtID map[string]*envRepoTarget + ordering []string +} + +type envRepoTarget struct { + key string + repositoryID, branchSlug string + worktreeID string + worktreePath string + worktreeBranch string + position int + errorMessage string + status string + mergedAt, deletedAt *time.Time + createdAt, updatedAt time.Time +} + +// normalize builds the final ownership graph in memory: one environment per +// task (deduplicated, backfilled when missing), one repository row per +// (repository, branch slug), and a session-to-environment link for every +// session. Any conflicting identity, path, or branch fails the migration. +func (c *worktreeCutover) normalize(tx *sqlx.Tx) error { + c.pickSurvivingEnvironments() + c.mergeLegacyEnvRepoRows() + c.mergeFlatEnvironmentFields() + if err := c.backfillMissingEnvironments(tx); err != nil { + return err + } + c.mergeSessionWorktrees() + c.registerWorktreeIdentities() + c.bindTargetsToEnvironments() + c.linkSessions() + + if len(c.conflicts) > 0 { + return fmt.Errorf("cutover: %d conflicting legacy ownership row(s):\n- %s", + len(c.conflicts), strings.Join(c.conflicts, "\n- ")) + } + return nil +} + +// pickSurvivingEnvironments keeps the most recently updated environment per +// task and re-points sessions that referenced a collapsed row. +func (c *worktreeCutover) pickSurvivingEnvironments() { + for taskID, envs := range c.taskEnvs { + if len(envs) == 0 { + continue + } + sort.SliceStable(envs, func(i, j int) bool { + if !envs[i].updatedAt.Equal(envs[j].updatedAt) { + return envs[i].updatedAt.After(envs[j].updatedAt) + } + return envs[i].createdAt.After(envs[j].createdAt) + }) + winner := envs[0] + c.taskEnvIDs[taskID] = winner.id + for _, loser := range envs[1:] { + c.loserEnvIDs[loser.id] = true + // Sessions pointing at a loser re-point to the winner. + for _, s := range c.sessions { + if s.taskEnvironmentID == loser.id { + c.sessionEnvIDs[s.id] = winner.id + } + } + } + } +} + +// mergeLegacyEnvRepoRows folds every pre-existing environment-repository row +// into its task's targets. Loser env rows re-home their repositories to the +// winner. +func (c *worktreeCutover) mergeLegacyEnvRepoRows() { + for _, row := range c.envRepos { + env, ok := c.envs[row.envID] + if !ok { + c.conflicts = append(c.conflicts, fmt.Sprintf( + "task_environment_repos row %s references missing environment %s", row.id, row.envID)) + continue + } + targets := c.targetsForTask(env.taskID) + if err := targets.mergeLegacyEnvRepo(row); err != nil { + c.conflicts = append(c.conflicts, fmt.Sprintf("environment %s repository row: %v", row.envID, err)) + } + } +} + +// mergeFlatEnvironmentFields merges the deprecated flat worktree columns of +// the surviving environments. +func (c *worktreeCutover) mergeFlatEnvironmentFields() { + for _, env := range c.envs { + if c.taskEnvIDs[env.taskID] != env.id || (env.repositoryID == "" && env.worktreeID == "") { + continue + } + targets := c.targetsForTask(env.taskID) + if err := targets.mergeFlatEnv(env); err != nil { + c.conflicts = append(c.conflicts, fmt.Sprintf("environment %s flat worktree fields: %v", env.id, err)) + } + } +} + +// mergeSessionWorktrees folds every legacy session-worktree row into the +// session's task targets. +func (c *worktreeCutover) mergeSessionWorktrees() { + for _, wt := range c.sessionWts { + session, ok := c.sessions[wt.sessionID] + if !ok { + continue // already reported in load + } + targets := c.targetsForTask(session.taskID) + if err := targets.mergeSessionWorktree(wt); err != nil { + c.conflicts = append(c.conflicts, fmt.Sprintf( + "session %s worktree %s: %v", wt.sessionID, wt.worktreeID, err)) + } + } +} + +// bindTargetsToEnvironments binds every task's targets to its surviving +// environment. +func (c *worktreeCutover) bindTargetsToEnvironments() { + for taskID, targets := range c.tasks { + envID := c.taskEnvIDs[taskID] + if envID == "" { + c.conflicts = append(c.conflicts, fmt.Sprintf( + "task %s has session worktrees but no resolvable environment", taskID)) + continue + } + targets.envID = envID + } +} + +func (c *worktreeCutover) targetsForTask(taskID string) *taskWorktreeTargets { + targets, ok := c.tasks[taskID] + if !ok { + targets = &taskWorktreeTargets{ + byKey: make(map[string]*envRepoTarget), + byWtID: make(map[string]*envRepoTarget), + } + c.tasks[taskID] = targets + } + return targets +} + +// backfillMissingEnvironments creates a normalized environment for every task +// that has sessions but no environment, mirroring the legacy startup backfill +// (now folded into this one-time cutover). +func (c *worktreeCutover) backfillMissingEnvironments(tx *sqlx.Tx) error { + tasksWithSessions := make(map[string]*legacySession) + for _, s := range c.sessions { + if _, ok := c.taskEnvIDs[s.taskID]; !ok { + if _, seen := tasksWithSessions[s.taskID]; !seen { + tasksWithSessions[s.taskID] = s + } + } + } + for taskID, sample := range tasksWithSessions { + executorType, err := c.resolveExecutorType(tx, sample.executorID) + if err != nil { + return err + } + envID := uuid.New().String() + c.taskEnvIDs[taskID] = envID + c.envs[envID] = &legacyEnv{ + id: envID, taskID: taskID, + executorType: executorType, + executorID: sample.executorID, + executorProfileID: sample.executorProfileID, + status: taskEnvironmentStatusStopped, + containerID: sample.containerID, + createdAt: sample.startedAt, + updatedAt: sample.startedAt, + } + for _, s := range c.sessions { + if s.taskID == taskID && c.sessionEnvIDs[s.id] == "" { + c.sessionEnvIDs[s.id] = envID + } + } + } + return nil +} + +// resolveExecutorType looks up a session's executor type. A missing executor +// row means a legacy session whose executor was deleted: default to +// "local_pc" exactly like the legacy backfill. Any other error aborts the +// cutover instead of silently guessing. +func (c *worktreeCutover) resolveExecutorType(tx *sqlx.Tx, executorID string) (string, error) { + if executorID == "" { + return executorTypeLocalPC, nil + } + if cached, ok := c.executorTypes[executorID]; ok { + return cached, nil + } + var executorType string + err := tx.QueryRow(tx.Rebind(`SELECT type FROM executors WHERE id = ?`), executorID).Scan(&executorType) + if errors.Is(err, sql.ErrNoRows) { + c.executorTypes[executorID] = executorTypeLocalPC + return executorTypeLocalPC, nil + } + if err != nil { + return "", fmt.Errorf("cutover: lookup executor type for %s: %w", executorID, err) + } + c.executorTypes[executorID] = executorType + return executorType, nil +} + +// linkSessions records the final environment for every session: its own +// task's surviving environment unless it retains a valid reference (including +// borrowed environments owned by another task). +func (c *worktreeCutover) linkSessions() { + for _, s := range c.sessions { + current := c.sessionEnvIDs[s.id] + if current != "" { + if c.loserEnvIDs[current] { + current = "" + } + if _, ok := c.envs[current]; ok && current != "" { + continue // valid surviving reference + } + } + c.sessionEnvIDs[s.id] = c.taskEnvIDs[s.taskID] + } +} + +// registerWorktreeIdentities ensures every physical worktree is owned by +// exactly one environment-repository key. +func (c *worktreeCutover) registerWorktreeIdentities() { + for _, targets := range c.tasks { + for _, key := range targets.ordering { + target := targets.byKey[key] + if target.worktreeID == "" { + continue + } + if existing, ok := targets.byWtID[target.worktreeID]; ok && existing != target { + c.conflicts = append(c.conflicts, fmt.Sprintf( + "worktree %s claimed by repository slots %q and %q", + target.worktreeID, existing.key, target.key)) + continue + } + targets.byWtID[target.worktreeID] = target + } + } +} diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go new file mode 100644 index 0000000000..8c23c04c2a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go @@ -0,0 +1,508 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" +) + +// rowForKey returns (creating if needed) the normalized repo target for a +// (repository, branch slug) key in this task. +func (t *taskWorktreeTargets) rowForKey(repositoryID, branchSlug string) *envRepoTarget { + key := repositoryID + "\x00" + branchSlug + if target, ok := t.byKey[key]; ok { + return target + } + target := &envRepoTarget{ + key: key, + repositoryID: repositoryID, + branchSlug: branchSlug, + } + t.byKey[key] = target + t.ordering = append(t.ordering, key) + return target +} + +// targetForWorktree finds the keyed target already carrying a physical +// worktree identity, or nil. +func (t *taskWorktreeTargets) targetForWorktree(worktreeID string) *envRepoTarget { + if worktreeID == "" { + return nil + } + if target, ok := t.byWtID[worktreeID]; ok { + return target + } + for _, key := range t.ordering { + if t.byKey[key].worktreeID == worktreeID { + return t.byKey[key] + } + } + return nil +} + +// mergeLegacyEnvRepo merges a pre-existing environment-repository row. These +// rows are the canonical source and win field conflicts (except for the +// physical-worktree identity, which must agree everywhere). +func (t *taskWorktreeTargets) mergeLegacyEnvRepo(row legacyEnvRepo) error { + target := t.rowForKey(row.repositoryID, row.branchSlug) + if err := target.claimWorktree(row.worktreeID, row.worktreePath, row.worktreeBranch, row.position, row.errorMessage, row.createdAt, row.updatedAt, worktreeRepoStatusActive); err != nil { + return err + } + return target.mergeCreation(row.createdAt, row.updatedAt) +} + +// mergeFlatEnv merges the deprecated flat worktree columns of the surviving +// environment. +func (t *taskWorktreeTargets) mergeFlatEnv(env *legacyEnv) error { + if existing := t.targetForWorktree(env.worktreeID); existing != nil { + if err := existing.verifyWorktree(env.worktreeID, env.worktreePath, env.worktreeBranch); err != nil { + return err + } + if existing.status == "" { + existing.status = worktreeRepoStatusActive + } + return nil + } + if env.repositoryID == "" && env.worktreeID == "" { + return nil + } + target := t.rowForKey(env.repositoryID, "") + return target.claimWorktree(env.worktreeID, env.worktreePath, env.worktreeBranch, 0, "", env.createdAt, env.updatedAt, worktreeRepoStatusActive) +} + +// mergeSessionWorktree folds a legacy session-worktree row into the task's +// targets. Matching is by worktree identity first (legacy rows could carry an +// empty repository_id), then by (repository, branch slug). +func (t *taskWorktreeTargets) mergeSessionWorktree(wt legacySessionWorktree) error { + if wt.worktreeID == "" { + return nil + } + if existing := t.targetForWorktree(wt.worktreeID); existing != nil { + return existing.verifyWorktree(wt.worktreeID, wt.worktreePath, wt.worktreeBranch) + } + target := t.rowForKey(wt.repositoryID, wt.branchSlug) + status := wt.status + if status == "" { + status = worktreeRepoStatusActive + } + if err := target.claimWorktree(wt.worktreeID, wt.worktreePath, wt.worktreeBranch, wt.position, "", wt.createdAt, wt.updatedAt, status); err != nil { + return err + } + target.mergedAt = wt.mergedAt + target.deletedAt = wt.deletedAt + return nil +} + +// claimWorktree sets or verifies the physical worktree identity and its +// path/branch on the target. Later sources keep their timestamps but must +// agree on identity, path, and branch. +func (t *envRepoTarget) claimWorktree(worktreeID, path, branch string, position int, errorMessage string, createdAt, updatedAt time.Time, status string) error { + if t.worktreeID == "" { + t.fillWorktree(worktreeID, path, branch, position, errorMessage, createdAt, updatedAt, status) + return nil + } + if worktreeID != "" && worktreeID != t.worktreeID { + return fmt.Errorf("worktree %s conflicts with %s", worktreeID, t.worktreeID) + } + return t.verifyWorktree(worktreeID, path, branch) +} + +// fillWorktree stamps a freshly claimed environment-repository target. +func (t *envRepoTarget) fillWorktree(worktreeID, path, branch string, position int, errorMessage string, createdAt, updatedAt time.Time, status string) { + t.worktreeID = worktreeID + t.worktreePath = path + t.worktreeBranch = branch + if position != 0 || t.position == 0 { + t.position = position + } + if errorMessage != "" { + t.errorMessage = errorMessage + } + if t.status == "" { + t.status = status + } + if t.createdAt.IsZero() { + t.createdAt = createdAt + } + if t.updatedAt.IsZero() { + t.updatedAt = updatedAt + } +} + +func (t *envRepoTarget) mergeCreation(createdAt, updatedAt time.Time) error { + if t.createdAt.IsZero() { + t.createdAt = createdAt + } + if t.updatedAt.IsZero() { + t.updatedAt = updatedAt + } + return nil +} + +// verifyWorktree asserts a second source agrees on the physical worktree +// identity, path, and branch. +func (t *envRepoTarget) verifyWorktree(worktreeID, path, branch string) error { + if worktreeID != "" && worktreeID != t.worktreeID { + return fmt.Errorf("worktree %s conflicts with %s", worktreeID, t.worktreeID) + } + if path != "" && t.worktreePath != "" && path != t.worktreePath { + return fmt.Errorf("path %q conflicts with %q", path, t.worktreePath) + } + if branch != "" && t.worktreeBranch != "" && branch != t.worktreeBranch { + return fmt.Errorf("branch %q conflicts with %q", branch, t.worktreeBranch) + } + return nil +} + +// cutoverBuildShadows creates the final-shape shadow tables. +func (r *Repository) cutoverBuildShadows(tx *sqlx.Tx) error { + if _, err := tx.Exec(finalTaskEnvironmentsDDL("task_environments_shadow")); err != nil { + return fmt.Errorf("cutover: create task_environments_shadow: %w", err) + } + if _, err := tx.Exec(finalTaskEnvironmentReposDDL("task_environment_repos_shadow", "task_environments_shadow")); err != nil { + return fmt.Errorf("cutover: create task_environment_repos_shadow: %w", err) + } + return r.maybeFailCutover("create_shadow") +} + +// insertNormalized copies the surviving environments into the shadow table +// and inserts every normalized repository row and session link. +func (r *Repository) insertNormalized(c *worktreeCutover, tx *sqlx.Tx) error { + for taskID, envID := range c.taskEnvIDs { + env, ok := c.envs[envID] + if !ok { + return fmt.Errorf("cutover: internal: environment %s for task %s missing", envID, taskID) + } + if _, err := tx.Exec(tx.Rebind(` + INSERT INTO task_environments_shadow ( + id, task_id, executor_type, executor_id, executor_profile_id, + control_port, status, workspace_path, container_id, sandbox_id, + task_dir_name, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + env.id, taskID, env.executorType, env.executorID, env.executorProfileID, + env.controlPort, env.status, env.workspacePath, env.containerID, + env.sandboxID, env.taskDirName, env.createdAt, env.updatedAt); err != nil { + return fmt.Errorf("cutover: insert shadow environment %s: %w", env.id, err) + } + } + if err := r.maybeFailCutover("copy_envs"); err != nil { + return err + } + + for taskID, targets := range c.tasks { + for _, key := range targets.ordering { + target := targets.byKey[key] + if err := c.insertRepoRow(tx, taskID, targets.envID, target); err != nil { + return err + } + } + } + if err := r.maybeFailCutover("backfill_repos"); err != nil { + return err + } + + for sessionID, envID := range c.sessionEnvIDs { + if envID == "" { + continue + } + if _, err := tx.Exec(tx.Rebind( + `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), + envID, sessionID); err != nil { + return fmt.Errorf("cutover: link session %s to environment %s: %w", sessionID, envID, err) + } + } + if err := r.maybeFailCutover("link_sessions"); err != nil { + return err + } + return nil +} + +func (c *worktreeCutover) insertRepoRow(tx *sqlx.Tx, taskID, envID string, target *envRepoTarget) error { + if envID == "" { + return fmt.Errorf("cutover: internal: no environment resolved for task %s (repo %q)", taskID, target.repositoryID) + } + var mergedAt, deletedAt interface{} + if target.mergedAt != nil { + mergedAt = *target.mergedAt + } + if target.deletedAt != nil { + deletedAt = *target.deletedAt + } + if _, err := tx.Exec(tx.Rebind(` + INSERT INTO task_environment_repos_shadow ( + id, task_environment_id, repository_id, branch_slug, + worktree_id, worktree_path, worktree_branch, position, + error_message, status, created_at, updated_at, merged_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), + uuid.New().String(), envID, target.repositoryID, target.branchSlug, + target.worktreeID, target.worktreePath, target.worktreeBranch, target.position, + target.errorMessage, target.status, target.createdAt, target.updatedAt, + mergedAt, deletedAt); err != nil { + return fmt.Errorf("cutover: insert shadow repo row for task %s: %w", taskID, err) + } + return nil +} + +// validateCutover proves the normalized graph is complete and consistent +// before the schema swap: the full legacy worktree inventory (identity, path, +// branch) matches the shadow inventory, every session resolves to a surviving +// environment, and the shadow schema is exactly the final shape. +func (r *Repository) validateCutover(c *worktreeCutover, tx *sqlx.Tx) error { + if mismatch := c.worktreeInventoryMismatch(); mismatch != "" { + return fmt.Errorf("cutover: worktree inventory mismatch: %s", mismatch) + } + + // Every session that referenced a worktree must resolve to an + // environment that owns that worktree. + for _, wt := range c.sessionWts { + envID := c.sessionEnvIDs[wt.sessionID] + if envID == "" { + return fmt.Errorf("cutover: session %s lost its environment", wt.sessionID) + } + targets, ok := c.tasks[c.sessionTasks[wt.sessionID]] + if !ok || targets.envID != envID || !targets.ownsWorktree(wt.worktreeID) { + return fmt.Errorf("cutover: session %s worktree %s has no environment-repository owner", + wt.sessionID, wt.worktreeID) + } + } + + if err := r.maybeFailCutover("validate"); err != nil { + return err + } + return r.checkShadowFinalSchema(tx) +} + +// worktreeInventoryMismatch compares the legacy and normalized worktree +// identity/path/branch sets, returning a diagnostic when they differ. +func (c *worktreeCutover) worktreeInventoryMismatch() string { + oldInventory := c.legacyWorktreeInventory() + newInventory := make(map[string]bool) + for _, targets := range c.tasks { + for _, key := range targets.ordering { + target := targets.byKey[key] + if target.worktreeID == "" || target.status == worktreeRepoStatusDeleted || target.deletedAt != nil { + continue + } + newInventory[worktreeInventoryKey(target.worktreeID, target.worktreePath, target.worktreeBranch)] = true + } + } + + var missing, extra []string + for key := range oldInventory { + if !newInventory[key] { + missing = append(missing, key) + } + } + for key := range newInventory { + if !oldInventory[key] { + extra = append(extra, key) + } + } + if len(missing) == 0 && len(extra) == 0 { + return "" + } + return fmt.Sprintf("missing %d, extra %d:\n missing: %s\n extra: %s", + len(missing), len(extra), strings.Join(missing, ", "), strings.Join(extra, ", ")) +} + +// legacyWorktreeInventory returns the complete pre-cutover worktree +// identity/path/branch set across session worktrees, environment-repository +// rows, and flat environment columns. +func (c *worktreeCutover) legacyWorktreeInventory() map[string]bool { + inventory := make(map[string]bool) + for _, wt := range c.sessionWts { + if wt.worktreeID == "" || wt.status == worktreeRepoStatusDeleted || wt.deletedAt != nil { + continue + } + inventory[worktreeInventoryKey(wt.worktreeID, wt.worktreePath, wt.worktreeBranch)] = true + } + for _, row := range c.envRepos { + if row.worktreeID == "" { + continue + } + inventory[worktreeInventoryKey(row.worktreeID, row.worktreePath, row.worktreeBranch)] = true + } + for _, env := range c.envs { + if env.worktreeID == "" || c.loserEnvIDs[env.id] { + continue + } + inventory[worktreeInventoryKey(env.worktreeID, env.worktreePath, env.worktreeBranch)] = true + } + return inventory +} + +// ownsWorktree reports whether one of the task's repository targets carries +// the physical worktree identity. +func (t *taskWorktreeTargets) ownsWorktree(worktreeID string) bool { + for _, key := range t.ordering { + if t.byKey[key].worktreeID == worktreeID { + return true + } + } + return false +} + +func worktreeInventoryKey(id, path, branch string) string { + return id + "\x00" + path + "\x00" + branch +} + +// checkShadowFinalSchema asserts the shadow tables expose exactly the final +// column sets (no flat worktree columns, lifecycle columns present). +func (r *Repository) checkShadowFinalSchema(tx *sqlx.Tx) error { + envColumns, err := r.tableColumns(tx, "task_environments_shadow") + if err != nil { + return err + } + for _, legacy := range []string{"repository_id", "worktree_id", "worktree_path", "worktree_branch"} { + if envColumns[legacy] { + return fmt.Errorf("cutover: task_environments_shadow still carries legacy column %s", legacy) + } + } + for _, required := range []string{"id", "task_id", "executor_type", "status", "workspace_path", "created_at", "updated_at"} { + if !envColumns[required] { + return fmt.Errorf("cutover: task_environments_shadow missing final column %s", required) + } + } + + repoColumns, err := r.tableColumns(tx, "task_environment_repos_shadow") + if err != nil { + return err + } + for _, required := range []string{"id", "task_environment_id", "repository_id", "branch_slug", + "worktree_id", "worktree_path", "worktree_branch", "position", "error_message", + "status", "created_at", "updated_at", "merged_at", "deleted_at"} { + if !repoColumns[required] { + return fmt.Errorf("cutover: task_environment_repos_shadow missing final column %s", required) + } + } + return nil +} + +// tableColumns returns the column-name set of a table for either engine. +func (r *Repository) tableColumns(tx *sqlx.Tx, table string) (map[string]bool, error) { + columns := make(map[string]bool) + var rows *sql.Rows + var err error + if dialect.IsPostgres(r.db.DriverName()) { + rows, err = tx.Query(` + SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND table_schema = current_schema()`, table) + } else { + rows, err = tx.Query(`SELECT name FROM pragma_table_info(?)`, table) + } + if err != nil { + return nil, fmt.Errorf("cutover: read columns of %s: %w", table, err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + columns[name] = true + } + return columns, rows.Err() +} + +// cutoverSwap drops the legacy schema and renames the shadow tables into +// place. The environment-repository table is dropped before the environment +// table because PostgreSQL refuses to drop a table referenced by a foreign +// key; SQLite has FK enforcement disabled for the swap. Postgres constraint +// names are restored to their canonical form afterwards. +func (r *Repository) cutoverSwap(tx *sqlx.Tx) error { + if _, err := tx.Exec(`DROP TABLE task_session_worktrees`); err != nil { + return fmt.Errorf("cutover: drop task_session_worktrees: %w", err) + } + // Preview-build session-delete cleanup jobs are invalid under the + // task-owned model; purge them before any cleanup worker can claim them. + if _, err := tx.Exec(tx.Rebind(`DELETE FROM task_resource_cleanup_jobs WHERE trigger = ?`), "session_delete"); err != nil { + return fmt.Errorf("cutover: purge preview session_delete cleanup jobs: %w", err) + } + if err := r.maybeFailCutover("drop_legacy"); err != nil { + return err + } + if _, err := tx.Exec(`DROP TABLE task_environment_repos`); err != nil { + return fmt.Errorf("cutover: drop legacy task_environment_repos: %w", err) + } + if _, err := tx.Exec(`DROP TABLE task_environments`); err != nil { + return fmt.Errorf("cutover: drop legacy task_environments: %w", err) + } + if _, err := tx.Exec(`ALTER TABLE task_environments_shadow RENAME TO task_environments`); err != nil { + return fmt.Errorf("cutover: rename task_environments_shadow: %w", err) + } + if _, err := tx.Exec(`ALTER TABLE task_environment_repos_shadow RENAME TO task_environment_repos`); err != nil { + return fmt.Errorf("cutover: rename task_environment_repos_shadow: %w", err) + } + if err := r.maybeFailCutover("swap"); err != nil { + return err + } + if dialect.IsPostgres(r.db.DriverName()) { + if err := r.cutoverRenamePostgresConstraints(tx); err != nil { + return err + } + } + for _, stmt := range []string{ + `CREATE INDEX IF NOT EXISTS idx_task_environments_task_id ON task_environments(task_id)`, + `CREATE INDEX IF NOT EXISTS idx_task_environments_status ON task_environments(status)`, + `CREATE INDEX IF NOT EXISTS idx_task_environment_repos_env_id ON task_environment_repos(task_environment_id)`, + `CREATE INDEX IF NOT EXISTS idx_task_environment_repos_repository_id ON task_environment_repos(repository_id)`, + } { + if _, err := tx.Exec(stmt); err != nil { + return fmt.Errorf("cutover: recreate index: %w", err) + } + } + return nil +} + +// cutoverRenamePostgresConstraints restores the canonical constraint names +// after the shadow tables take the real names. SQLite does not persist +// constraint names, so this is Postgres-only. The shadow names are looked up +// dynamically because PostgreSQL truncates over-long identifiers to 63 bytes. +func (r *Repository) cutoverRenamePostgresConstraints(tx *sqlx.Tx) error { + renames := []struct { + table, canonical string + matchShadow string + constraintType string + }{ + {"task_environments", "task_environments_task_id_fkey", "task_environments_shadow_task_id_fkey", "f"}, + {"task_environment_repos", "task_environment_repos_task_environment_id_fkey", "task_environment_repos_shadow_task_environment_id_fkey", "f"}, + {"task_environment_repos", "task_environment_repos_env_repo_branch_key", "task_environment_repos_shadow_task_environment_id_", "u"}, + } + for _, rename := range renames { + var constraintName string + err := tx.QueryRow(tx.Rebind(` + SELECT conname FROM pg_constraint + WHERE conrelid = (SELECT oid FROM pg_class WHERE relname = ? AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) + AND conname LIKE ? + AND contype = ?`), rename.table, rename.matchShadow+"%", rename.constraintType).Scan(&constraintName) + if err != nil { + return fmt.Errorf("cutover: find postgres constraint on %s: %w", rename.table, err) + } + if _, err := tx.Exec(fmt.Sprintf( + `ALTER TABLE %s RENAME CONSTRAINT %s TO %s`, rename.table, constraintName, rename.canonical)); err != nil { + return fmt.Errorf("cutover: rename postgres constraint %s: %w", constraintName, err) + } + } + return nil +} + +// maybeFailCutover is a test-only failpoint: when the repository's +// failCutoverAfter step matches, the migration aborts so tests can prove the +// transaction restores the complete pre-upgrade state. +func (r *Repository) maybeFailCutover(step string) error { + if r.failCutoverAfter == "" { + return nil + } + if r.failCutoverAfter == step { + r.failCutoverAfter = "" + return errors.New("injected cutover failure at " + step) + } + return nil +} diff --git a/apps/backend/internal/task/repository/task_environment_repo_test.go b/apps/backend/internal/task/repository/task_environment_repo_test.go index 67e7f2f57a..c578ccebd4 100644 --- a/apps/backend/internal/task/repository/task_environment_repo_test.go +++ b/apps/backend/internal/task/repository/task_environment_repo_test.go @@ -10,7 +10,6 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/sqlite" - "github.com/kandev/kandev/internal/worktree" ) // Phase 0 tests for the multi-repo TaskEnvironment / TaskEnvironmentRepo schema. @@ -202,88 +201,34 @@ func TestTaskEnvironmentRepo_CascadeDeleteOnEnvDelete(t *testing.T) { } } -func TestTaskEnvironmentRepo_BackfillFromLegacyEnv(t *testing.T) { - // Backfill is run by initSchema. Simulate a "legacy" environment by inserting - // directly into task_environments with repository_id set, then re-opening the - // repository so initSchema re-runs the backfill on the existing data. +func TestTaskEnvironmentRepo_LegacyFlatColumnsAreGone(t *testing.T) { + // The flat task_environments worktree columns were removed by the + // one-time cutover; only the versioned migration knows the legacy shape. + // The migration-path coverage lives in the sqlite package's + // TestCutover_NormalizesLegacyFlatEnvironment. tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "backfill.db") + dbPath := filepath.Join(tmpDir, "final.db") dbConn, err := db.OpenSQLite(dbPath) if err != nil { t.Fatalf("open db: %v", err) } sqlxDB := sqlx.NewDb(dbConn, "sqlite3") - repo, err := sqlite.NewWithDB(sqlxDB, sqlxDB, nil) - if err != nil { + if _, err := sqlite.NewWithDB(sqlxDB, sqlxDB, nil); err != nil { t.Fatalf("new repo: %v", err) } - if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil { - t.Fatalf("worktree store: %v", err) - } + t.Cleanup(func() { _ = sqlxDB.Close() }) - ctx := context.Background() - newTaskWithRepo(t, repo, "task-env-6") - - // Insert a legacy-shaped env (no per-repo row exists). - if _, err := sqlxDB.Exec(` + _, err = sqlxDB.Exec(` INSERT INTO task_environments ( - id, task_id, repository_id, executor_type, executor_id, executor_profile_id, - control_port, status, - worktree_id, worktree_path, worktree_branch, workspace_path, - container_id, sandbox_id, task_dir_name, + id, task_id, repository_id, executor_type, worktree_id, + worktree_path, worktree_branch, workspace_path, created_at, updated_at - ) VALUES ('legacy-env', 'task-env-6', 'repo-legacy', 'local_pc', '', '', - 0, 'ready', + ) VALUES ('legacy-env', 'task-env-6', 'repo-legacy', 'local_pc', 'wt-legacy', '/wt/legacy', 'main', '', - '', '', '', datetime('now'), datetime('now')) - `); err != nil { - t.Fatalf("insert legacy env: %v", err) - } - - // Verify no per-repo row yet. - list, err := repo.ListTaskEnvironmentRepos(ctx, "legacy-env") - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 0 { - t.Fatalf("expected 0 per-repo rows pre-backfill, got %d", len(list)) - } - - // Re-open the repository to retrigger initSchema → backfill. - if err := repo.Close(); err != nil { - t.Fatalf("close: %v", err) - } - dbConn2, err := db.OpenSQLite(dbPath) - if err != nil { - t.Fatalf("reopen db: %v", err) - } - sqlxDB2 := sqlx.NewDb(dbConn2, "sqlite3") - repo2, err := sqlite.NewWithDB(sqlxDB2, sqlxDB2, nil) - if err != nil { - t.Fatalf("reopen repo: %v", err) - } - defer func() { - _ = repo2.Close() - _ = sqlxDB2.Close() - }() - - list, err = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") - if err != nil { - t.Fatalf("list after reopen: %v", err) - } - if len(list) != 1 { - t.Fatalf("expected 1 backfilled row, got %d", len(list)) - } - got := list[0] - if got.RepositoryID != "repo-legacy" || got.WorktreeID != "wt-legacy" || got.WorktreeBranch != "main" { - t.Errorf("backfilled row mismatch: %+v", got) - } - - // Idempotency: a second backfill must not duplicate rows. - list, _ = repo2.ListTaskEnvironmentRepos(ctx, "legacy-env") - if len(list) != 1 { - t.Errorf("expected backfill to be idempotent, got %d rows", len(list)) + `) + if err == nil { + t.Fatal("expected legacy flat columns to be absent from the final schema") } } diff --git a/apps/backend/internal/task/service/handoff_cascade_test.go b/apps/backend/internal/task/service/handoff_cascade_test.go index 829d8206c5..cccf26d051 100644 --- a/apps/backend/internal/task/service/handoff_cascade_test.go +++ b/apps/backend/internal/task/service/handoff_cascade_test.go @@ -883,7 +883,7 @@ func (f *flippingActiveSessionReader) ListTaskSessions(_ context.Context, taskID return []*models.TaskSession{{ID: f.sessionID, TaskID: f.taskID}}, nil } -func (f *flippingActiveSessionReader) ListTaskSessionWorktrees(context.Context, string) ([]*models.TaskSessionWorktree, error) { +func (f *flippingActiveSessionReader) ListTaskSessionWorktrees(context.Context, string) ([]*models.TaskEnvironmentRepo, error) { return nil, nil } diff --git a/apps/backend/internal/task/service/handoff_materialize.go b/apps/backend/internal/task/service/handoff_materialize.go index 72183e4d82..5b09c9aefe 100644 --- a/apps/backend/internal/task/service/handoff_materialize.go +++ b/apps/backend/internal/task/service/handoff_materialize.go @@ -121,7 +121,7 @@ func pickPrimarySession(sessions []*models.TaskSession) *models.TaskSession { // kind is single_repo for a single worktree, multi_repo for >1 // worktrees, and falls back to whatever the group declared when there // are zero worktrees. -func buildMaterialization(group *orchmodels.WorkspaceGroup, worktrees []*models.TaskSessionWorktree, envID string) (kind, path, restoreCfg string) { +func buildMaterialization(group *orchmodels.WorkspaceGroup, worktrees []*models.TaskEnvironmentRepo, envID string) (kind, path, restoreCfg string) { switch len(worktrees) { case 0: // No worktrees but the session may still own a TaskEnvironment diff --git a/apps/backend/internal/task/service/handoff_materialize_test.go b/apps/backend/internal/task/service/handoff_materialize_test.go index 67dba8d3a4..93d9b63bbd 100644 --- a/apps/backend/internal/task/service/handoff_materialize_test.go +++ b/apps/backend/internal/task/service/handoff_materialize_test.go @@ -17,7 +17,7 @@ import ( type fakeSessionReader struct { mu sync.Mutex sessions map[string][]*models.TaskSession // taskID → sessions - worktrees map[string][]*models.TaskSessionWorktree // sessionID → worktrees + worktrees map[string][]*models.TaskEnvironmentRepo // sessionID → worktrees tasks map[string]*models.Task // sessionsWithRunningExecutor records sessions that should be // reported as still bound to an active executor by @@ -29,7 +29,7 @@ type fakeSessionReader struct { func newFakeSessionReader() *fakeSessionReader { return &fakeSessionReader{ sessions: map[string][]*models.TaskSession{}, - worktrees: map[string][]*models.TaskSessionWorktree{}, + worktrees: map[string][]*models.TaskEnvironmentRepo{}, tasks: map[string]*models.Task{}, sessionsWithRunningExecutor: map[string]bool{}, } @@ -41,7 +41,7 @@ func (f *fakeSessionReader) ListTaskSessions(_ context.Context, taskID string) ( return f.sessions[taskID], nil } -func (f *fakeSessionReader) ListTaskSessionWorktrees(_ context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) { +func (f *fakeSessionReader) ListTaskSessionWorktrees(_ context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) { f.mu.Lock() defer f.mu.Unlock() return f.worktrees[sessionID], nil @@ -81,7 +81,7 @@ func TestMarkOwnerSessionMaterialized_FlipsOwnership(t *testing.T) { sr := newFakeSessionReader() sr.sessions["owner"] = []*models.TaskSession{{ID: "s1", IsPrimary: true, TaskID: "owner"}} - sr.worktrees["s1"] = []*models.TaskSessionWorktree{{ + sr.worktrees["s1"] = []*models.TaskEnvironmentRepo{{ WorktreeID: "wt-1", RepositoryID: "repo-1", WorktreePath: "/tmp/wt", WorktreeBranch: "feature/x", }} @@ -159,7 +159,7 @@ func TestMarkOwnerSessionMaterialized_SharedGroupFirstMemberFlips(t *testing.T) sr := newFakeSessionReader() sr.sessions["first-launcher"] = []*models.TaskSession{{ID: "sFL", IsPrimary: true, TaskID: "first-launcher"}} - sr.worktrees["sFL"] = []*models.TaskSessionWorktree{{ + sr.worktrees["sFL"] = []*models.TaskEnvironmentRepo{{ WorktreeID: "wt-fl", RepositoryID: "repo-1", WorktreePath: "/tmp/wt-fl", WorktreeBranch: "feature/x", }} @@ -189,7 +189,7 @@ func TestMarkOwnerSessionMaterialized_Idempotent(t *testing.T) { sr := newFakeSessionReader() sr.sessions["owner"] = []*models.TaskSession{{ID: "s1", IsPrimary: true, TaskID: "owner"}} - sr.worktrees["s1"] = []*models.TaskSessionWorktree{{ + sr.worktrees["s1"] = []*models.TaskEnvironmentRepo{{ WorktreePath: "/tmp/wt-changed", }} svc := newMaterializerService(t, tasks, groups, sr) diff --git a/apps/backend/internal/task/service/handoff_service.go b/apps/backend/internal/task/service/handoff_service.go index 8bda1f0603..c4b9a7bf39 100644 --- a/apps/backend/internal/task/service/handoff_service.go +++ b/apps/backend/internal/task/service/handoff_service.go @@ -55,7 +55,7 @@ type WorkspaceGroupRepo interface { // the sqlite task repository. type SessionWorktreeReader interface { ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) - ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) + ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskEnvironmentRepo, error) GetTask(ctx context.Context, id string) (*models.Task, error) // HasExecutorRunningRow tells cleanup whether a session still has // an executors_running row — i.e. an agent is (or recently was) diff --git a/apps/backend/internal/task/service/resource_cleanup_jobs.go b/apps/backend/internal/task/service/resource_cleanup_jobs.go index b45eb39792..0840ef0c8a 100644 --- a/apps/backend/internal/task/service/resource_cleanup_jobs.go +++ b/apps/backend/internal/task/service/resource_cleanup_jobs.go @@ -562,6 +562,19 @@ func (s *Service) PrepareTaskResourceCleanup( operationID string, deleteEnvironmentRow bool, ) error { + // Reserve the durable lifecycle barrier BEFORE capturing the inventory. + // Session and worktree creation serialize against the owning task row and + // reject new ownership while this prepared barrier is active, so the + // snapshot below cannot miss a resource admitted mid-preparation. + job, err := s.persistTaskResourceCleanup(ctx, taskID, trigger, operationID, + nil, nil, nil, taskEnvironmentCleanup{}, true) + if err != nil { + return err + } + if s.resourceCleanups == nil { + return nil + } + barrierOperationID := job.OperationID sessions, err := s.sessions.ListTaskSessions(ctx, taskID) if err != nil { return fmt.Errorf("list task sessions for cleanup snapshot: %w", err) @@ -578,10 +591,18 @@ func (s *Service) PrepareTaskResourceCleanup( if err != nil { return fmt.Errorf("lookup task environment for cleanup snapshot: %w", err) } - _, err = s.persistTaskResourceCleanup(ctx, taskID, trigger, operationID, - sessions, worktrees, stopTargets, - taskEnvironmentCleanup{env: taskEnv, deleteRow: deleteEnvironmentRow}, true) - return err + snapshot := taskResourceCleanupSnapshot{ + Sessions: sessions, Worktrees: worktrees, + StopTargets: persistStopTargets(stopTargets), + TaskEnvironment: taskEnv, + DeleteEnvironmentRow: deleteEnvironmentRow, + LegacyWorktreeCleanup: s.hasLegacyWorktreeCleanup(), + } + encoded, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("encode task resource cleanup snapshot: %w", err) + } + return s.resourceCleanups.UpdateTaskResourceCleanupSnapshot(ctx, barrierOperationID, string(encoded)) } func (s *Service) StartPreparedTaskResourceCleanup(ctx context.Context, operationID string) error { diff --git a/apps/backend/internal/task/service/resource_cleanup_jobs_test.go b/apps/backend/internal/task/service/resource_cleanup_jobs_test.go index 837bfec61d..f78365e9c0 100644 --- a/apps/backend/internal/task/service/resource_cleanup_jobs_test.go +++ b/apps/backend/internal/task/service/resource_cleanup_jobs_test.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/activity" "github.com/kandev/kandev/internal/agentruntime" orchmodels "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/worktree" @@ -611,11 +612,23 @@ func TestArchiveCleanupPreservesHistoricalWorktreeBranchMetadata(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() seedCleanupTaskAndSession(t, repo, "task-history", "session-history") - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "session-worktree-history", SessionID: "session-history", WorktreeID: "worktree-history", - RepositoryID: "repo-history", WorktreePath: "/tmp/history", WorktreeBranch: "feature/history", + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-history", TaskID: "task-history", ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "session-worktree-history", TaskEnvironmentID: "env-history", WorktreeID: "worktree-history", + RepositoryID: "repo-history", WorktreePath: "/tmp/history", WorktreeBranch: "feature/history", + }}, }); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-history") + if err != nil { + t.Fatalf("load session: %v", err) + } + session.TaskEnvironmentID = "env-history" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session to env: %v", err) } svc.setCleanupDoneForTestHook(make(chan struct{}, 1)) if err := svc.ArchiveTask(ctx, "task-history"); err != nil { @@ -729,11 +742,12 @@ func TestDeleteInheritedSubtaskPreservesChildMaterializedWorkspaceForParent(t *t ctx := context.Background() seedParentChildWorkspace(t, repo, "ws-shared-delete", "wf-shared-delete", "task-parent", "task-child") if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-shared", - TaskID: "task-child", - Status: models.TaskEnvironmentStatusReady, - WorktreeID: "worktree-shared", - WorktreePath: "/tmp/worktree-shared", + ID: "env-shared", + TaskID: "task-child", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{ + {RepositoryID: "repo-shared", WorktreeID: "worktree-shared", WorktreePath: "/tmp/worktree-shared"}, + }, }); err != nil { t.Fatalf("create child-materialized environment: %v", err) } @@ -1054,9 +1068,11 @@ func TestTaskResourceCleanupMissingResourcesSucceed(t *testing.T) { destroyer *stubDestroyer }{ { - name: "worktree", - environment: &models.TaskEnvironment{ID: "env-gone-worktree", WorktreeID: "worktree-gone"}, - destroyer: &stubDestroyer{worktreeErr: fmt.Errorf("worktree cleanup: %w", worktree.ErrWorktreeNotFound)}, + name: "worktree", + environment: &models.TaskEnvironment{ID: "env-gone-worktree", Repos: []*models.TaskEnvironmentRepo{ + {WorktreeID: "worktree-gone"}, + }}, + destroyer: &stubDestroyer{worktreeErr: fmt.Errorf("worktree cleanup: %w", worktree.ErrWorktreeNotFound)}, }, { name: "task environment row", diff --git a/apps/backend/internal/task/service/resource_cleanup_task_owned_test.go b/apps/backend/internal/task/service/resource_cleanup_task_owned_test.go new file mode 100644 index 0000000000..c22aca5117 --- /dev/null +++ b/apps/backend/internal/task/service/resource_cleanup_task_owned_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/worktree" +) + +// TestDeleteTaskCleanupFindsWorktreeAfterLastSessionDeletedAndRestart is the +// core task-owned cleanup contract: after the task's only session is deleted +// and the backend restarts (a fresh service over the same database), delete +// inventory must still discover the task-owned worktree through the +// environment, and the durable snapshot must carry it after the task row is +// gone. +func TestDeleteTaskCleanupFindsWorktreeAfterLastSessionDeletedAndRestart(t *testing.T) { + ctx := context.Background() + _, _, repo := createTestService(t) + seedCleanupTaskAndSession(t, repo, "task-zero-session", "session-zero-session") + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-zero-session", TaskID: "task-zero-session", ExecutorType: "worktree", + WorkspacePath: "/tmp/zero", Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-zero-session", RepositoryID: "repo-zero-session", + WorktreeID: "wt-zero-session", WorktreePath: "/tmp/zero/repo", + WorktreeBranch: "feature/zero", Status: "active", + }}, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-zero-session") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + session.TaskEnvironmentID = "env-zero-session" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session: %v", err) + } + + // Delete the only session through the orchestrator-style deletion. + if err := repo.DeleteTaskSession(ctx, "session-zero-session"); err != nil { + t.Fatalf("delete session: %v", err) + } + + // Restart: a fresh service over the same database, wired to a real + // worktree manager backed by the same schema. + svc2 := newServiceOverRepository(t, repo) + mgr := newCleanupTestWorktreeManager(t, repo) + svc2.SetWorktreeCleanup(mgr) + worktrees, err := svc2.gatherWorktreesForDelete(ctx, "task-zero-session") + if err != nil { + t.Fatalf("gather worktrees: %v", err) + } + if len(worktrees) != 1 || worktrees[0].ID != "wt-zero-session" { + t.Fatalf("zero-session inventory = %+v, want wt-zero-session", worktrees) + } + + if err := svc2.DeleteTask(ctx, "task-zero-session"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + var encoded string + if err := repo.DB().QueryRowContext(ctx, ` + SELECT resource_snapshot FROM task_resource_cleanup_jobs + WHERE task_id = ? AND trigger = 'delete' + `, "task-zero-session").Scan(&encoded); err != nil { + t.Fatalf("load delete snapshot: %v", err) + } + var snapshot taskResourceCleanupSnapshot + if err := json.Unmarshal([]byte(encoded), &snapshot); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if len(snapshot.Worktrees) != 1 || snapshot.Worktrees[0].ID != "wt-zero-session" { + t.Fatalf("snapshot worktrees = %+v, want wt-zero-session after task-row deletion", snapshot.Worktrees) + } +} + +// TestArchiveCleanupFindsWorktreeAfterLastSessionDeleted proves the archive +// path inventories the task-owned worktree with zero sessions and schedules +// its asynchronous removal. +func TestArchiveCleanupFindsWorktreeAfterLastSessionDeleted(t *testing.T) { + ctx := context.Background() + svc, _, repo := createTestService(t) + seedCleanupTaskAndSession(t, repo, "task-archive-zero", "session-archive-zero") + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-archive-zero", TaskID: "task-archive-zero", ExecutorType: "worktree", + WorkspacePath: "/tmp/archive-zero", Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{ + ID: "env-repo-archive-zero", RepositoryID: "repo-archive-zero", + WorktreeID: "wt-archive-zero", WorktreePath: "/tmp/archive-zero/repo", + WorktreeBranch: "feature/archive-zero", Status: "active", + }}, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session-archive-zero") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + session.TaskEnvironmentID = "env-archive-zero" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("link session: %v", err) + } + if err := repo.DeleteTaskSession(ctx, "session-archive-zero"); err != nil { + t.Fatalf("delete session: %v", err) + } + + svc.setCleanupDoneForTestHook(make(chan struct{}, 1)) + if err := svc.ArchiveTask(ctx, "task-archive-zero"); err != nil { + t.Fatalf("ArchiveTask: %v", err) + } + waitForCleanupDone(t, svc) + + // The env-repo row remains (physical removal is async via the manager; + // the archive marks the task archived and schedules cleanup). + task, err := repo.GetTask(ctx, "task-archive-zero") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.ArchivedAt == nil { + t.Fatal("task must be archived") + } + env, err := repo.GetTaskEnvironment(ctx, "env-archive-zero") + if err != nil { + t.Fatalf("GetTaskEnvironment: %v", err) + } + if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-archive-zero" { + t.Fatalf("env after archive = %+v, want retained worktree row", env) + } +} + +// newCleanupTestWorktreeManager wires a real worktree manager over the test +// repository's database so cleanup inventory reads the actual +// task_environment_repos rows. +func newCleanupTestWorktreeManager(t *testing.T, repo *sqliterepo.Repository) *worktree.Manager { + t.Helper() + store, err := worktree.NewSQLiteStore(sqlx.NewDb(repo.DB(), "sqlite3"), sqlx.NewDb(repo.DB(), "sqlite3")) + if err != nil { + t.Fatalf("worktree store: %v", err) + } + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + mgr, err := worktree.NewManager(worktree.Config{ + Enabled: true, + TasksBasePath: t.TempDir(), + }, store, log) + if err != nil { + t.Fatalf("worktree manager: %v", err) + } + return mgr +} + +// newServiceOverRepository builds a fresh task service over an existing test +// repository, simulating a backend restart on the same database. +func newServiceOverRepository(t *testing.T, repo *sqliterepo.Repository) *Service { + t.Helper() + eventBus := NewMockEventBus() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewService(Repos{ + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + RepositoryCleanup: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, + ResourceCleanups: repo, + }, eventBus, log, RepositoryDiscoveryConfig{}) + svc.SetWorkspaceBootstrapper(repo) + svc.SetWorkflowStepGetter(&testWorkflowStepGetter{repo: repo}) + return svc +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index cb55a3f596..a0f2432685 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -108,8 +108,8 @@ type ProviderDefaultBranchProber interface { ProbeDefaultBranch(ctx context.Context, provider, owner, name string) (string, error) } -// BranchMaterializer creates a worktree on disk and persists a -// task_session_worktrees row for a newly added task_repository row, without +// BranchMaterializer creates a worktree on disk and persists the +// task_environment_repos row for a newly added task_repository row, without // restarting the agent. Used by AddBranchToTask so MCP-driven "add a branch // to this task" actually surfaces the new worktree in the UI on the next // poll, rather than waiting for a session relaunch. diff --git a/apps/backend/internal/task/service/service_task_environments.go b/apps/backend/internal/task/service/service_task_environments.go index 9c2f2ed032..9b885c59fe 100644 --- a/apps/backend/internal/task/service/service_task_environments.go +++ b/apps/backend/internal/task/service/service_task_environments.go @@ -301,7 +301,7 @@ func (s *Service) ResetTaskEnvironment(ctx context.Context, taskID string, opts zap.String("executor_type", env.ExecutorType), zap.String("container_id", env.ContainerID), zap.String("sandbox_id", env.SandboxID), - zap.String("worktree_id", env.WorktreeID), + zap.Int("worktree_count", len(environmentWorktreeIDs(env))), zap.Bool("push_branch", opts.PushBranch)) if opts.PushBranch { @@ -336,7 +336,8 @@ func (s *Service) teardownEnvironmentResources(ctx context.Context, env *models. if cause := context.Cause(ctx); cause != nil { return cause } - if env.ContainerID == "" && env.SandboxID == "" && env.WorktreeID == "" { + worktreeIDs := environmentWorktreeIDs(env) + if env.ContainerID == "" && env.SandboxID == "" && len(worktreeIDs) == 0 { return nil } if s.envDestroyer == nil { @@ -366,13 +367,13 @@ func (s *Service) teardownEnvironmentResources(ctx context.Context, env *models. errs = append(errs, fmt.Errorf("destroy sandbox %s: %w", env.SandboxID, err)) } } - if env.WorktreeID != "" { + for _, worktreeID := range worktreeIDs { if err := contextError(); err != nil { return err } - if err := s.envDestroyer.DestroyWorktree(ctx, env.WorktreeID); err != nil { + if err := s.envDestroyer.DestroyWorktree(ctx, worktreeID); err != nil { if !errors.Is(err, worktree.ErrWorktreeNotFound) { - errs = append(errs, fmt.Errorf("destroy worktree %s: %w", env.WorktreeID, err)) + errs = append(errs, fmt.Errorf("destroy worktree %s: %w", worktreeID, err)) } } } @@ -381,3 +382,18 @@ func (s *Service) teardownEnvironmentResources(ctx context.Context, env *models. } return errors.Join(errs...) } + +// environmentWorktreeIDs returns the physical worktree identities recorded on +// the environment's repository rows. +func environmentWorktreeIDs(env *models.TaskEnvironment) []string { + if env == nil { + return nil + } + var ids []string + for _, repo := range env.Repos { + if repo != nil && repo.WorktreeID != "" { + ids = append(ids, repo.WorktreeID) + } + } + return ids +} diff --git a/apps/backend/internal/task/service/service_task_environments_test.go b/apps/backend/internal/task/service/service_task_environments_test.go index f87f60dcbd..0b75f645e3 100644 --- a/apps/backend/internal/task/service/service_task_environments_test.go +++ b/apps/backend/internal/task/service/service_task_environments_test.go @@ -158,7 +158,7 @@ func TestResetTaskEnvironment_DestroysEachResourceTypeAndDeletesRow(t *testing.T TaskID: "task-1", ContainerID: "container-abc", SandboxID: "sandbox-xyz", - WorktreeID: "wt-1", + Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1"}}, }} destroyer := &stubDestroyer{} svc := newResetTestService(t, repo) @@ -189,7 +189,8 @@ func TestTeardownEnvironmentResources_CancellationStopsBeforeNextResource(t *tes svc.SetEnvironmentDestroyer(destroyer) err := svc.teardownEnvironmentResources(ctx, &models.TaskEnvironment{ - ContainerID: "container-1", SandboxID: "sandbox-1", WorktreeID: "worktree-1", + ContainerID: "container-1", SandboxID: "sandbox-1", + Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "worktree-1"}}, }) if !errors.Is(err, context.Canceled) { @@ -207,7 +208,7 @@ func TestTeardownEnvironmentResources_GenericWorktreeFailureRemainsError(t *test svc.SetEnvironmentDestroyer(&stubDestroyer{worktreeErr: worktreeErr}) err := svc.teardownEnvironmentResources(context.Background(), &models.TaskEnvironment{ - WorktreeID: "worktree-1", + Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "worktree-1"}}, }) if !errors.Is(err, worktreeErr) { @@ -281,7 +282,7 @@ func TestResetTaskEnvironment_TeardownIsBestEffortAcrossResources(t *testing.T) ID: "env-1", TaskID: "task-1", ContainerID: "container-abc", - WorktreeID: "wt-1", + Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1"}}, }} destroyer := &stubDestroyer{containerErr: errors.New("docker unreachable")} svc := newResetTestService(t, repo) @@ -305,10 +306,9 @@ func TestResetTaskEnvironment_TeardownIsBestEffortAcrossResources(t *testing.T) func TestResetTaskEnvironment_PushBranchFailureAbortsResetBeforeTeardown(t *testing.T) { repo := &stubEnvRepo{env: &models.TaskEnvironment{ - ID: "env-1", - TaskID: "task-1", - WorktreeID: "wt-1", - WorktreePath: "/tmp/worktree", + ID: "env-1", + TaskID: "task-1", + Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1", WorktreePath: "/tmp/worktree"}}, }} destroyer := &stubDestroyer{pushErr: errors.New("remote rejected")} svc := newResetTestService(t, repo) diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 6927e09be9..75e966b442 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -2597,10 +2597,11 @@ func (s *Service) filterSharedWorktreesForTaskCleanup( filtered = append(filtered, wt) continue } - if err := guard.ReleaseWorktreeReference(ctx, wt); err != nil { - errs = append(errs, fmt.Errorf("release shared worktree reference %s: %w", wt.ID, err)) - continue - } + // A worktree borrowed by another task is protected by the single + // environment-repository row it shares. There is no per-session + // reference to release: marking the row deleted would destroy the + // borrower's workspace. The environment ownership transfer handled + // earlier in the flow re-homes the row to the borrower. s.logger.Info("preserving worktree still referenced by another active task", zap.String("task_id", taskID), zap.String("worktree_id", wt.ID), @@ -2749,7 +2750,6 @@ func (s *Service) taskOwnsSessionWorktree( } func cleanupEligibleWorktrees(worktrees []*worktree.Worktree, env *models.TaskEnvironment, preserveExecutorRows map[string]struct{}) []*worktree.Worktree { - worktrees = excludeEnvironmentWorktree(worktrees, env) if len(preserveExecutorRows) == 0 || len(worktrees) == 0 { return worktrees } @@ -2800,20 +2800,6 @@ func (s *Service) cleanupTaskEnvironment( return nil } -func excludeEnvironmentWorktree(worktrees []*worktree.Worktree, env *models.TaskEnvironment) []*worktree.Worktree { - if env == nil || env.WorktreeID == "" || len(worktrees) == 0 { - return worktrees - } - filtered := worktrees[:0] - for _, wt := range worktrees { - if wt == nil || wt.ID == env.WorktreeID { - continue - } - filtered = append(filtered, wt) - } - return filtered -} - // ListTasks returns all tasks for a workflow func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { if err := s.authorizeWorkflowID(ctx, workflowID); err != nil { diff --git a/apps/backend/internal/task/service/service_tasks_stop_test.go b/apps/backend/internal/task/service/service_tasks_stop_test.go index e08d5aa3ad..4e1f2060fa 100644 --- a/apps/backend/internal/task/service/service_tasks_stop_test.go +++ b/apps/backend/internal/task/service/service_tasks_stop_test.go @@ -637,11 +637,10 @@ func TestCleanupTaskResources_SkipsBorrowedInheritedWorktree(t *testing.T) { ctx := context.Background() seedParentChildWorkspace(t, repo, "ws-inherited", "wf-inherited", "parent-task", "child-task") if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-parent", - TaskID: "parent-task", - WorktreeID: "wt-parent", - WorktreePath: "/tmp/parent-worktree", - Status: models.TaskEnvironmentStatusReady, + ID: "env-parent", + TaskID: "parent-task", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{RepositoryID: "repo-parent", WorktreeID: "wt-parent", WorktreePath: "/tmp/parent-worktree"}}, }); err != nil { t.Fatalf("create parent environment: %v", err) } @@ -706,11 +705,10 @@ func TestCleanupTaskResources_PreservesOwnedEnvironmentWithActiveInheritedChild( ctx := context.Background() seedParentChildWorkspace(t, repo, "ws-shared", "wf-shared", "parent-task", "child-task") if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-parent", - TaskID: "parent-task", - WorktreeID: "wt-parent", - WorktreePath: "/tmp/parent-worktree", - Status: models.TaskEnvironmentStatusReady, + ID: "env-parent", + TaskID: "parent-task", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{RepositoryID: "repo-parent", WorktreeID: "wt-parent", WorktreePath: "/tmp/parent-worktree"}}, }); err != nil { t.Fatalf("create parent environment: %v", err) } @@ -762,11 +760,10 @@ func TestDeleteTask_TransfersBorrowedEnvironmentBeforeDeletingOwner(t *testing.T ctx := context.Background() seedParentChildWorkspace(t, repo, "ws-transfer", "wf-transfer", "parent-task", "child-task") if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-parent", - TaskID: "parent-task", - WorktreeID: "wt-parent", - WorktreePath: "/tmp/parent-worktree", - Status: models.TaskEnvironmentStatusReady, + ID: "env-parent", + TaskID: "parent-task", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{RepositoryID: "repo-parent", WorktreeID: "wt-parent", WorktreePath: "/tmp/parent-worktree"}}, }); err != nil { t.Fatalf("create parent environment: %v", err) } @@ -798,11 +795,10 @@ func TestCleanupTaskResources_TransfersBorrowedEnvironmentBeforeCascadeDelete(t ctx := context.Background() seedParentChildWorkspace(t, repo, "ws-cascade-transfer", "wf-cascade-transfer", "parent-task", "child-task") if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ - ID: "env-parent", - TaskID: "parent-task", - WorktreeID: "wt-parent", - WorktreePath: "/tmp/parent-worktree", - Status: models.TaskEnvironmentStatusReady, + ID: "env-parent", + TaskID: "parent-task", + Status: models.TaskEnvironmentStatusReady, + Repos: []*models.TaskEnvironmentRepo{{RepositoryID: "repo-parent", WorktreeID: "wt-parent", WorktreePath: "/tmp/parent-worktree"}}, }); err != nil { t.Fatalf("create parent environment: %v", err) } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 32ff09af89..e616b0d46e 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -2698,8 +2698,9 @@ func TestService_CleanupDestructiveTaskResourcesPreservesSharedWorktree(t *testi if got := cleanup.cleanedIDs(); len(got) != 0 { t.Fatalf("physically cleaned worktrees = %v, want none", got) } - if got := cleanup.releasedIDs(); len(got) != 1 || got[0] != wt.ID { - t.Fatalf("released worktrees = %v, want [%s]", got, wt.ID) + // The single environment-repository row is shared; nothing is released. + if got := cleanup.releasedIDs(); len(got) != 0 { + t.Fatalf("released worktrees = %v, want none (shared row is preserved)", got) } if got := cleanup.excludedSessions; len(got) != 1 || got[0] != session.ID { t.Fatalf("excluded sessions = %v, want [%s]", got, session.ID) diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 06ce6eeb7a..81d6e3ec0f 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -716,7 +716,7 @@ type workspaceRepositoryProjection struct { repoName string } -func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID string, sessionWorktrees []*models.TaskSessionWorktree, info *lifecycle.WorkspaceInfo) error { +func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID string, sessionWorktrees []*models.TaskEnvironmentRepo, info *lifecycle.WorkspaceInfo) error { if taskID == "" || s.taskRepos == nil || s.repoEntities == nil { return nil } @@ -725,7 +725,7 @@ func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID s } else if task != nil { info.WorkspaceID = task.WorkspaceID } - worktreesByIdentity := make(map[workspaceWorktreeKey]*models.TaskSessionWorktree, len(sessionWorktrees)) + worktreesByIdentity := make(map[workspaceWorktreeKey]*models.TaskEnvironmentRepo, len(sessionWorktrees)) for _, worktree := range sessionWorktrees { if worktree != nil && worktree.RepositoryID != "" { worktreesByIdentity[workspaceWorktreeKey{repositoryID: worktree.RepositoryID, branchSlug: worktree.BranchSlug}] = worktree diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index eb05e5f7eb..327a96a580 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/worktree" "github.com/stretchr/testify/require" ) @@ -149,6 +150,18 @@ func (nilTaskSessionRepo) SetSessionMetadataKey(context.Context, string, string, panic("SetSessionMetadataKey should not be called for a nil session") } +// createTestEnvironment creates a task environment row for tests that seed +// environment-repository worktrees directly. +func createTestEnvironment(t *testing.T, repo *sqliterepo.Repository, envID, taskID string) { + t.Helper() + if err := repo.CreateTaskEnvironment(context.Background(), &models.TaskEnvironment{ + ID: envID, TaskID: taskID, ExecutorType: "worktree", + WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment(%s): %v", envID, err) + } +} + func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() @@ -177,15 +190,16 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { t.Fatalf("failed to create session: %v", err) } - // Add a worktree to the session - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "wt1", - SessionID: "session-1", - WorktreeID: "wid1", - RepositoryID: "repo1", - WorktreePath: "/tmp/worktrees/session-1", - WorktreeBranch: "feature/test", - CreatedAt: now, + // Add a worktree to the session's environment + createTestEnvironment(t, repo, "env-123", "task-123") + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "wt1", + TaskEnvironmentID: "env-123", + WorktreeID: "wid1", + RepositoryID: "repo1", + WorktreePath: "/tmp/worktrees/session-1", + WorktreeBranch: "feature/test", + CreatedAt: now, }); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -266,15 +280,17 @@ func TestGetWorkspaceInfoForSession_ProjectsPersistedWorktreeIdentity(t *testing t.Fatalf("CreateTaskRepository: %v", err) } if err := repo.CreateTaskSession(ctx, &models.TaskSession{ - ID: "session-recovery", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, + ID: "session-recovery", TaskID: "task-123", TaskEnvironmentID: "env-recovery", + State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: "session-worktree-recovery", SessionID: "session-recovery", WorktreeID: "worktree-recovery", RepositoryID: "repo-recovery", + createTestEnvironment(t, repo, "env-recovery", "task-123") + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: "session-worktree-recovery", TaskEnvironmentID: "env-recovery", WorktreeID: "worktree-recovery", RepositoryID: "repo-recovery", BranchSlug: "feature-recovery", WorktreePath: "/tasks/task-recovery/api-feature-recovery", CreatedAt: now, }); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) + t.Fatalf("CreateTaskEnvironmentRepo: %v", err) } info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-recovery") @@ -310,16 +326,18 @@ func TestGetWorkspaceInfoForSession_ProjectsDistinctWorktreesForSameRepositoryBr } } if err := repo.CreateTaskSession(ctx, &models.TaskSession{ - ID: "session-multi-recovery", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, + ID: "session-multi-recovery", TaskID: "task-123", TaskEnvironmentID: "env-multi-recovery", + State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - for _, worktree := range []*models.TaskSessionWorktree{ - {ID: "session-worktree-one", SessionID: "session-multi-recovery", WorktreeID: "worktree-one", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-one", CreatedAt: now}, - {ID: "session-worktree-two", SessionID: "session-multi-recovery", WorktreeID: "worktree-two", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-two", CreatedAt: now}, + createTestEnvironment(t, repo, "env-multi-recovery", "task-123") + for _, worktree := range []*models.TaskEnvironmentRepo{ + {ID: "session-worktree-one", TaskEnvironmentID: "env-multi-recovery", WorktreeID: "worktree-one", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-one", CreatedAt: now}, + {ID: "session-worktree-two", TaskEnvironmentID: "env-multi-recovery", WorktreeID: "worktree-two", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-two", CreatedAt: now}, } { - if err := repo.CreateTaskSessionWorktree(ctx, worktree); err != nil { - t.Fatalf("CreateTaskSessionWorktree %q: %v", worktree.BranchSlug, err) + if err := repo.CreateTaskEnvironmentRepo(ctx, worktree); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo %q: %v", worktree.BranchSlug, err) } } @@ -375,11 +393,12 @@ func TestGetWorkspaceInfoForSession_UsesRepositoryDefaultBranchIdentity(t *testi if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-default-branch", TaskID: "task-123", RepositoryID: "repo-default-branch"}); err != nil { t.Fatalf("CreateTaskRepository: %v", err) } - if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-default-branch", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-default-branch", TaskID: "task-123", TaskEnvironmentID: "env-default-branch", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ID: "session-worktree-default-branch", SessionID: "session-default-branch", WorktreeID: "worktree-default-branch", RepositoryID: "repo-default-branch", BranchSlug: "main", CreatedAt: now}); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) + createTestEnvironment(t, repo, "env-default-branch", "task-123") + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ID: "session-worktree-default-branch", TaskEnvironmentID: "env-default-branch", WorktreeID: "worktree-default-branch", RepositoryID: "repo-default-branch", BranchSlug: "main", CreatedAt: now}); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo: %v", err) } info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-default-branch") @@ -409,19 +428,20 @@ func TestGetWorkspaceInfoForSession_UsesHashDisambiguatedBranchIdentities(t *tes t.Fatalf("CreateTaskRepository %q: %v", taskRepo.ID, err) } } - if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-hash-branches", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-hash-branches", TaskID: "task-123", TaskEnvironmentID: "env-hash-branches", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { t.Fatalf("CreateTaskSession: %v", err) } plans := worktree.BuildBranchIdentityPlans([]worktree.BranchIdentityInput{ {RepositoryID: "repo-hash-branches", BaseBranch: "feature/a", DefaultBranch: "main", PRNumber: 101, Position: 0}, {RepositoryID: "repo-hash-branches", BaseBranch: "feature-a", DefaultBranch: "main", PRNumber: 202, Position: 1}, }) + createTestEnvironment(t, repo, "env-hash-branches", "task-123") for index, plan := range plans { - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: fmt.Sprintf("session-worktree-hash-%d", index), SessionID: "session-hash-branches", WorktreeID: fmt.Sprintf("worktree-hash-%d", index), + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: fmt.Sprintf("session-worktree-hash-%d", index), TaskEnvironmentID: "env-hash-branches", WorktreeID: fmt.Sprintf("worktree-hash-%d", index), RepositoryID: "repo-hash-branches", BranchSlug: plan.IdentitySlug, CreatedAt: now, }); err != nil { - t.Fatalf("CreateTaskSessionWorktree %q: %v", plan.IdentitySlug, err) + t.Fatalf("CreateTaskEnvironmentRepo %q: %v", plan.IdentitySlug, err) } } @@ -459,11 +479,12 @@ func TestGetWorkspaceInfoForSession_ProjectsOnlyLifecycleValidRepositories(t *te t.Fatalf("CreateTaskRepository %q: %v", repositoryID, err) } } - if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-projection-validity", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-projection-validity", TaskID: "task-123", TaskEnvironmentID: "env-projection-validity", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { t.Fatalf("CreateTaskSession: %v", err) } - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ID: "session-worktree-projection-validity", SessionID: "session-projection-validity", WorktreeID: "worktree-projection-validity", RepositoryID: "repo-provider-recovery", BranchSlug: "main", CreatedAt: now}); err != nil { - t.Fatalf("CreateTaskSessionWorktree: %v", err) + createTestEnvironment(t, repo, "env-projection-validity", "task-123") + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ID: "session-worktree-projection-validity", TaskEnvironmentID: "env-projection-validity", WorktreeID: "worktree-projection-validity", RepositoryID: "repo-provider-recovery", BranchSlug: "main", CreatedAt: now}); err != nil { + t.Fatalf("CreateTaskEnvironmentRepo: %v", err) } info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-projection-validity") @@ -1021,27 +1042,29 @@ func TestGetWorkspaceInfoForSession_MultiRepoReturnsTaskRoot(t *testing.T) { now := time.Now().UTC() session := &models.TaskSession{ - ID: "session-multi", - TaskID: "task-123", - State: models.TaskSessionStateCompleted, - StartedAt: now, - UpdatedAt: now, + ID: "session-multi", + TaskID: "task-123", + TaskEnvironmentID: "env-session-worktrees", + State: models.TaskSessionStateCompleted, + StartedAt: now, + UpdatedAt: now, } if err := repo.CreateTaskSession(ctx, session); err != nil { t.Fatalf("create session: %v", err) } + createTestEnvironment(t, repo, "env-session-worktrees", "task-123") for i, path := range []string{ "/tmp/tasks/do-nothing_mvo/kandev", "/tmp/tasks/do-nothing_mvo/thm", } { - if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ - ID: fmt.Sprintf("wt%d", i), - SessionID: session.ID, - WorktreeID: fmt.Sprintf("wid%d", i), - RepositoryID: fmt.Sprintf("repo%d", i), - Position: i, - WorktreePath: path, - CreatedAt: now, + if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ + ID: fmt.Sprintf("wt%d", i), + TaskEnvironmentID: "env-session-worktrees", + WorktreeID: fmt.Sprintf("wid%d", i), + RepositoryID: fmt.Sprintf("repo%d", i), + Position: i, + WorktreePath: path, + CreatedAt: now, }); err != nil { t.Fatalf("create worktree %d: %v", i, err) } diff --git a/apps/backend/internal/worktree/errors.go b/apps/backend/internal/worktree/errors.go index 4301954e93..965b0425b0 100644 --- a/apps/backend/internal/worktree/errors.go +++ b/apps/backend/internal/worktree/errors.go @@ -68,6 +68,18 @@ var ( // usable characters after sanitization. Callers that pass an explicit slug // must populate something the filesystem can accept. ErrInvalidBranchSlug = errors.New("branch slug has no usable characters after sanitization") + + // ErrEnvironmentNotResolved is returned by the store when a worktree + // cannot be persisted because the session has no task environment yet + // (initial materialization). The manager treats it as a skip, not a + // failure — the launch's environment persistence records the worktree. + ErrEnvironmentNotResolved = errors.New("session has no task environment") + + // ErrTaskCleanupInProgress is returned by the store when the owning + // task has an active lifecycle cleanup barrier. The caller compensates + // the just-created physical worktree instead of admitting it after + // cleanup inventory was captured. + ErrTaskCleanupInProgress = errors.New("task cleanup in progress") ) // containsAuthFailure checks if git output indicates an authentication failure. diff --git a/apps/backend/internal/worktree/manager_cleanup.go b/apps/backend/internal/worktree/manager_cleanup.go index 9ce593def4..882671fd9e 100644 --- a/apps/backend/internal/worktree/manager_cleanup.go +++ b/apps/backend/internal/worktree/manager_cleanup.go @@ -180,14 +180,20 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch repoLock.Unlock() m.releaseRepoLock(wt.RepositoryPath) }() - activeReferences, err := m.CountActiveWorktreeReferences(ctx, wt.ID, []string{wt.SessionID}) + // CountActiveWorktreeReferences already counts only sessions of OTHER + // tasks referencing the owning environment. No exclusions are passed: + // the worktree record returned by GetWorktreeByID carries an arbitrary + // session of that environment, and excluding it could hide a borrower + // and authorize deletion of a workspace another task still holds. + activeReferences, err := m.CountActiveWorktreeReferences(ctx, wt.ID, nil) if err != nil { return fmt.Errorf("count active references for worktree %s: %w", wt.ID, err) } if activeReferences > 0 { - if err := m.ReleaseWorktreeReference(ctx, wt); err != nil { - return fmt.Errorf("release shared worktree reference %s: %w", wt.ID, err) - } + // The worktree is owned by a task environment that another task's + // session still references. The single environment-repository row is + // the only record, so there is no per-session reference to release — + // leave the row and directory untouched. m.logger.Info("preserved worktree still referenced by another task session", zap.String("worktree_id", wt.ID), zap.String("session_id", wt.SessionID), diff --git a/apps/backend/internal/worktree/manager_cleanup_shared_reference_test.go b/apps/backend/internal/worktree/manager_cleanup_shared_reference_test.go index 385490829e..6d8829ef86 100644 --- a/apps/backend/internal/worktree/manager_cleanup_shared_reference_test.go +++ b/apps/backend/internal/worktree/manager_cleanup_shared_reference_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/jmoiron/sqlx" @@ -18,14 +19,12 @@ func TestCleanupWorktrees_PreservesSharedActiveReference(t *testing.T) { ctx := context.Background() seedReferenceCleanupSession(t, store, "task-owner", "session-owner", models.TaskSessionStateRunning) seedReferenceCleanupSession(t, store, "task-borrower", "session-borrower", models.TaskSessionStateRunning) + // The borrower's session references the owner's environment. + if err := store.linkSessionToEnvironment(ctx, "session-borrower", "env-owner"); err != nil { + t.Fatalf("link borrower session: %v", err) + } wt := createReferenceCleanupWorktree(t, mgr, "task-owner", "session-owner") - borrowed := *wt - borrowed.TaskID = "task-borrower" - borrowed.SessionID = "session-borrower" - if err := store.CreateWorktree(ctx, &borrowed); err != nil { - t.Fatalf("create borrower worktree reference: %v", err) - } count, err := store.CountActiveWorktreeReferences(ctx, wt.ID, []string{"session-owner"}) if err != nil { t.Fatalf("count active worktree references: %v", err) @@ -41,8 +40,7 @@ func TestCleanupWorktrees_PreservesSharedActiveReference(t *testing.T) { if _, err := os.Stat(wt.Path); err != nil { t.Fatalf("shared worktree path should be preserved: %v", err) } - assertWorktreeReferenceStatus(t, store, wt.ID, "session-owner", StatusDeleted) - assertWorktreeReferenceStatus(t, store, wt.ID, "session-borrower", StatusActive) + assertWorktreeReferenceStatus(t, store, wt.ID, StatusActive) } func TestCleanupWorktrees_PreservesParentWorkspaceWhenInheritedSubtaskDeleted(t *testing.T) { @@ -55,24 +53,40 @@ func TestCleanupWorktrees_PreservesParentWorkspaceWhenInheritedSubtaskDeleted(t } parent := createReferenceCleanupWorktree(t, mgr, "task-parent", "session-parent") - child := *parent - child.TaskID = "task-child" - child.SessionID = "session-child" - if err := store.CreateWorktree(ctx, &child); err != nil { - t.Fatalf("create inherited subtask worktree reference: %v", err) + // The inherited subtask borrows the parent's environment. Its inventory + // (queried through its own task row) therefore contains no worktrees. + if err := store.linkSessionToEnvironment(ctx, "session-child", "env-parent"); err != nil { + t.Fatalf("link inherited subtask session: %v", err) } + childInventory, err := store.GetWorktreesByTaskID(ctx, "task-child") + if err != nil { + t.Fatalf("child inventory: %v", err) + } + if len(childInventory) != 0 { + t.Fatalf("child inventory = %d worktrees, want 0 (worktrees belong to the parent env)", len(childInventory)) + } + // The parent inventory must return exactly one row per environment + // repository, not one per session sharing the environment. + parentInventory, err := store.GetWorktreesByTaskID(ctx, "task-parent") + if err != nil { + t.Fatalf("parent inventory: %v", err) + } + if len(parentInventory) != 1 || parentInventory[0].ID != parent.ID { + t.Fatalf("parent inventory = %+v, want exactly one deduplicated worktree", parentInventory) + } + if _, err := store.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, "task-child"); err != nil { t.Fatalf("delete inherited subtask: %v", err) } - if err := mgr.CleanupWorktrees(ctx, []*Worktree{&child}); err != nil { - t.Fatalf("cleanup inherited subtask worktree: %v", err) + if err := mgr.CleanupWorktrees(ctx, childInventory); err != nil { + t.Fatalf("cleanup inherited subtask worktrees: %v", err) } if _, err := os.Stat(parent.Path); err != nil { t.Fatalf("parent workspace should survive inherited subtask deletion: %v", err) } - assertWorktreeReferenceStatus(t, store, parent.ID, "session-parent", StatusActive) + assertWorktreeReferenceStatus(t, store, parent.ID, StatusActive) var parentTasks int if err := store.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE id = ?`, "task-parent").Scan(&parentTasks); err != nil { t.Fatalf("count surviving parent task: %v", err) @@ -94,32 +108,21 @@ func TestCleanupWorktrees_RemovesLastActiveReference(t *testing.T) { if _, err := os.Stat(wt.Path); !os.IsNotExist(err) { t.Fatalf("last-reference worktree path should be removed, stat error = %v", err) } - assertWorktreeReferenceStatus(t, store, wt.ID, "session-owner", StatusDeleted) + assertWorktreeReferenceStatus(t, store, wt.ID, StatusDeleted) } func TestReleaseWorktreeReference_MissingAssociationIsIdempotent(t *testing.T) { mgr, store := newReferenceCleanupTestManager(t) ctx := context.Background() seedReferenceCleanupSession(t, store, "task-owner", "session-owner", models.TaskSessionStateCompleted) - seedReferenceCleanupSession(t, store, "task-borrower", "session-borrower", models.TaskSessionStateRunning) wt := createReferenceCleanupWorktree(t, mgr, "task-owner", "session-owner") - borrowed := *wt - borrowed.TaskID = "task-borrower" - borrowed.SessionID = "session-borrower" - if err := store.CreateWorktree(ctx, &borrowed); err != nil { - t.Fatalf("create borrower worktree reference: %v", err) - } - if _, err := store.db.ExecContext(ctx, `DELETE FROM task_session_worktrees WHERE session_id = ?`, wt.SessionID); err != nil { - t.Fatalf("delete owner worktree reference: %v", err) + if _, err := store.db.ExecContext(ctx, `DELETE FROM task_environment_repos WHERE worktree_id = ?`, wt.ID); err != nil { + t.Fatalf("delete worktree record: %v", err) } if err := mgr.ReleaseWorktreeReference(ctx, wt); err != nil { - t.Fatalf("release missing owner reference: %v", err) + t.Fatalf("release missing worktree record: %v", err) } - if _, err := os.Stat(wt.Path); err != nil { - t.Fatalf("shared worktree path should be preserved: %v", err) - } - assertWorktreeReferenceStatus(t, store, wt.ID, borrowed.SessionID, StatusActive) } func newReferenceCleanupTestManager(t *testing.T) (*Manager, *SQLiteStore) { @@ -164,14 +167,29 @@ func seedReferenceCleanupSession( `, taskID, taskID); err != nil { t.Fatalf("create task %s: %v", taskID, err) } + envID := "env-" + strings.TrimPrefix(taskID, "task-") if _, err := store.db.ExecContext(ctx, ` - INSERT INTO task_sessions (id, task_id, state, started_at, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - `, sessionID, taskID, state); err != nil { + INSERT INTO task_environments (id, task_id, executor_type, status, workspace_path, created_at, updated_at) + VALUES (?, ?, 'worktree', 'ready', '/tmp/' || ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, envID, taskID, taskID); err != nil { + t.Fatalf("create environment %s: %v", envID, err) + } + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO task_sessions (id, task_id, state, task_environment_id, started_at, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, sessionID, taskID, state, envID); err != nil { t.Fatalf("create session %s: %v", sessionID, err) } } +// linkSessionToEnvironment re-points a session at another environment, +// modeling a borrower session that shares an owner's workspace. +func (s *SQLiteStore) linkSessionToEnvironment(ctx context.Context, sessionID, envID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), envID, sessionID) + return err +} + func createReferenceCleanupWorktree(t *testing.T, mgr *Manager, taskID, sessionID string) *Worktree { t.Helper() wt, err := mgr.Create(context.Background(), CreateRequest{ @@ -190,16 +208,99 @@ func createReferenceCleanupWorktree(t *testing.T, mgr *Manager, taskID, sessionI return wt } -func assertWorktreeReferenceStatus(t *testing.T, store *SQLiteStore, worktreeID, sessionID, want string) { +func assertWorktreeReferenceStatus(t *testing.T, store *SQLiteStore, worktreeID, want string) { t.Helper() var got string if err := store.ro.QueryRowContext(context.Background(), ` - SELECT status FROM task_session_worktrees - WHERE worktree_id = ? AND session_id = ? - `, worktreeID, sessionID).Scan(&got); err != nil { - t.Fatalf("load worktree reference status: %v", err) + SELECT status FROM task_environment_repos + WHERE worktree_id = ? + `, worktreeID).Scan(&got); err != nil { + t.Fatalf("load worktree status: %v", err) } if got != want { - t.Fatalf("worktree reference status = %q, want %q", got, want) + t.Fatalf("worktree status = %q, want %q", got, want) } } + +// TestCreateWorktree_RejectedByTaskCleanupBarrier proves the worktree store +// refuses to admit a physical worktree once the owning task's lifecycle +// cleanup barrier is active, and that the manager compensates by removing the +// just-created directory. +func TestCreateWorktree_RejectedByTaskCleanupBarrier(t *testing.T) { + mgr, store := newReferenceCleanupTestManager(t) + ctx := context.Background() + seedReferenceCleanupSession(t, store, "task-barrier", "session-barrier", models.TaskSessionStateRunning) + + // First creation succeeds and leaves a live directory behind. + first := createReferenceCleanupWorktree(t, mgr, "task-barrier", "session-barrier") + if _, err := os.Stat(first.Path); err != nil { + t.Fatalf("first worktree dir missing: %v", err) + } + + // Archive preparation reserves its barrier before inventory capture. + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO task_resource_cleanup_jobs ( + id, operation_id, task_id, trigger, state, resource_snapshot, attempts, created_at, updated_at + ) VALUES ('barrier-job', 'op-barrier', 'task-barrier', 'archive', 'prepared', '{}', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); err != nil { + t.Fatalf("reserve barrier: %v", err) + } + + // A second materialization must be rejected: the physical directory the + // manager just created is compensated away. + second, err := mgr.Create(ctx, CreateRequest{ + TaskID: "task-barrier", + SessionID: "session-barrier", + TaskTitle: "Barrier", + RepositoryID: "repository", + RepositoryPath: initGitRepoWithRemote(t), + BaseBranch: "main", + TaskDirName: "task-barrier", + RepoName: "repository", + BranchSlug: "second", + }) + if err == nil { + t.Fatalf("expected barrier rejection, got worktree %s", second.ID) + } + if !strings.Contains(err.Error(), "task cleanup in progress") { + t.Fatalf("error = %v, want task cleanup barrier rejection", err) + } + if _, statErr := os.Stat(first.Path); statErr != nil { + t.Fatalf("pre-existing worktree dir must survive compensation: %v", statErr) + } +} + +// TestRemoveByID_NeverDeletesBorrowedWorktree locks in the borrow protection: +// GetWorktreeByID returns an arbitrary session of the owning environment, and +// removeWorktree must not exclude it when counting references — otherwise a +// borrower's workspace could be physically deleted. +func TestRemoveByID_NeverDeletesBorrowedWorktree(t *testing.T) { + mgr, store := newReferenceCleanupTestManager(t) + ctx := context.Background() + seedReferenceCleanupSession(t, store, "task-owner", "session-owner", models.TaskSessionStateRunning) + seedReferenceCleanupSession(t, store, "task-borrower", "session-borrower", models.TaskSessionStateRunning) + if err := store.linkSessionToEnvironment(ctx, "session-borrower", "env-owner"); err != nil { + t.Fatalf("link borrower session: %v", err) + } + + wt := createReferenceCleanupWorktree(t, mgr, "task-owner", "session-owner") + + // Prove the loaded record can carry the borrower's session: with the + // LEFT JOIN the session column is arbitrary, so force the exact shape + // the blocker described. + loaded, err := store.GetWorktreeByID(ctx, wt.ID) + if err != nil { + t.Fatalf("GetWorktreeByID: %v", err) + } + loaded.SessionID = "session-borrower" + + if err := mgr.RemoveByID(ctx, loaded.ID, true); err != nil { + t.Fatalf("RemoveByID: %v", err) + } + + // The borrowed worktree must survive both physically and as an active row. + if _, err := os.Stat(wt.Path); err != nil { + t.Fatalf("borrowed worktree path must be preserved: %v", err) + } + assertWorktreeReferenceStatus(t, store, wt.ID, StatusActive) +} diff --git a/apps/backend/internal/worktree/manager_state.go b/apps/backend/internal/worktree/manager_state.go index 8a83c55bfa..d4fad1497b 100644 --- a/apps/backend/internal/worktree/manager_state.go +++ b/apps/backend/internal/worktree/manager_state.go @@ -2,6 +2,7 @@ package worktree import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -15,18 +16,19 @@ import ( func (m *Manager) buildWorktreeRecord(worktreeID string, req CreateRequest, worktreePath, branchName string) *Worktree { now := time.Now() return &Worktree{ - ID: worktreeID, - SessionID: req.SessionID, - TaskID: req.TaskID, - RepositoryID: req.RepositoryID, - BranchSlug: requestBranchIdentitySlug(req), - RepositoryPath: req.RepositoryPath, - Path: worktreePath, - Branch: branchName, - BaseBranch: req.BaseBranch, - Status: StatusActive, - CreatedAt: now, - UpdatedAt: now, + ID: worktreeID, + SessionID: req.SessionID, + TaskID: req.TaskID, + TaskEnvironmentID: req.TaskEnvironmentID, + RepositoryID: req.RepositoryID, + BranchSlug: requestBranchIdentitySlug(req), + RepositoryPath: req.RepositoryPath, + Path: worktreePath, + Branch: branchName, + BaseBranch: req.BaseBranch, + Status: StatusActive, + CreatedAt: now, + UpdatedAt: now, } } @@ -51,8 +53,16 @@ func (m *Manager) persistAndCacheWorktree(ctx context.Context, wt *Worktree, req return nil } -// persistWorktree writes the worktree to persistent storage, logging a warning when -// session_id is missing and cleaning up the git worktree directory on failure. +// persistWorktree writes the worktree to persistent storage, logging a warning +// when session_id or a resolvable task environment is missing and cleaning up +// the git worktree directory on failure. +// +// Initial materialization runs before the executor persists the task +// environment, so the store cannot resolve an environment yet — that launch's +// persistence happens atomically in persistTaskEnvironment, and a worktree +// that cannot be recorded there is compensated. Resume, recreate, and +// mid-session materialization paths always have an environment and persist +// here. func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateRequest, worktreePath string) error { if req.SessionID == "" { m.logger.Warn("skipping worktree persistence: missing session_id", @@ -61,6 +71,13 @@ func (m *Manager) persistWorktree(ctx context.Context, wt *Worktree, req CreateR return nil } if err := m.store.CreateWorktree(ctx, wt); err != nil { + if errors.Is(err, ErrEnvironmentNotResolved) { + m.logger.Warn("skipping worktree persistence: session has no task environment yet", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.SessionID), + zap.String("worktree_id", wt.ID)) + return nil + } // Cleanup git worktree on store failure if cleanupErr := m.removeWorktreeDir(ctx, worktreePath, req.RepositoryPath); cleanupErr != nil { m.logger.Warn("failed to cleanup worktree after persist failure", zap.Error(cleanupErr)) diff --git a/apps/backend/internal/worktree/store.go b/apps/backend/internal/worktree/store.go index a79a368616..6c8e76be0a 100644 --- a/apps/backend/internal/worktree/store.go +++ b/apps/backend/internal/worktree/store.go @@ -9,179 +9,58 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" ) // SQLiteStore implements Store interface using SQLite. +// +// Physical worktrees are owned by task environments: every record is a row in +// task_environment_repos, and sessions reach them only through +// task_sessions.task_environment_id. The store resolves session-keyed lookups +// through that link; there is no session-to-worktree table. type SQLiteStore struct { db *sqlx.DB // writer ro *sqlx.DB // reader } // NewSQLiteStore creates a new SQLite-backed worktree store. -// It uses the provided writer and reader connections and ensures the task_session_worktrees table exists. +// It uses the provided writer and reader connections. The +// task_environment_repos schema is owned by the task repository's +// initializer; the store adds nothing on its own. func NewSQLiteStore(writer, reader *sqlx.DB) (*SQLiteStore, error) { - store := &SQLiteStore{db: writer, ro: reader} - if err := store.initSchema(); err != nil { - return nil, fmt.Errorf("failed to initialize worktree schema: %w", err) - } - return store, nil + return &SQLiteStore{db: writer, ro: reader}, nil } -// initSchema creates the task_session_worktrees table if it doesn't exist. -// `branch_slug` is required for multi-branch tasks (same repo, multiple -// branches): without it, reuse lookups by (session_id, repository_id) -// silently collapse two distinct worktrees into one, which then trickles -// down to a single on-disk directory shared between rows. -func (s *SQLiteStore) initSchema() error { - schema := ` - CREATE TABLE IF NOT EXISTS task_session_worktrees ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - worktree_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - branch_slug TEXT NOT NULL DEFAULT '', - position INTEGER DEFAULT 0, - worktree_path TEXT DEFAULT '', - worktree_branch TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, - merged_at TIMESTAMP, - deleted_at TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, - UNIQUE(session_id, worktree_id) - ); +// worktreeSelectCols is the SELECT projection shared by every worktree query. +// The session column is the ID of the session that resolved the environment +// (callers keyed on a session see their own session), and base_branch comes +// from that session's launch snapshot. +const worktreeSelectCols = ` + ter.worktree_id, + COALESCE(s.id, '') AS session_id, + te.task_id, + ter.repository_id, + r.local_path, + ter.worktree_path, + ter.worktree_branch, + COALESCE(ter.branch_slug, ''), + COALESCE(s.base_branch, ''), + ter.status, + ter.created_at, + ter.updated_at, + ter.merged_at, + ter.deleted_at, + ter.task_environment_id` - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); - ` - - if _, err := s.db.Exec(schema); err != nil { - return err - } - // Idempotent ALTER for upgrades. Pre-existing rows get branch_slug='' which - // matches the legacy single-branch identity exactly. - if _, err := s.db.Exec(`ALTER TABLE task_session_worktrees ADD COLUMN branch_slug TEXT NOT NULL DEFAULT ''`); err != nil { - // SQLite and Postgres report duplicate columns differently; treat both - // as success so the migration is replay-safe. - if !db.IsDuplicateColumnError(err) { - return err - } - } - return nil +// rowScanner abstracts *sql.Row and *sql.Rows so worktree rows scan through +// one helper. +type rowScanner interface { + Scan(dest ...interface{}) error } -// CreateWorktree persists a new worktree record. -func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { - if wt.ID == "" { - wt.ID = uuid.New().String() - } - if wt.SessionID == "" { - return fmt.Errorf("session ID is required to persist worktree") - } - if wt.Status == "" { - wt.Status = StatusActive - } - now := time.Now().UTC() - if wt.CreatedAt.IsZero() { - wt.CreatedAt = now - } - if wt.UpdatedAt.IsZero() { - wt.UpdatedAt = now - } - - _, err := s.db.ExecContext(ctx, s.db.Rebind(` - INSERT INTO task_session_worktrees ( - id, session_id, worktree_id, repository_id, branch_slug, position, - worktree_path, worktree_branch, status, - created_at, updated_at, merged_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id, worktree_id) DO UPDATE SET - repository_id = excluded.repository_id, - branch_slug = excluded.branch_slug, - worktree_path = excluded.worktree_path, - worktree_branch = excluded.worktree_branch, - status = excluded.status, - updated_at = excluded.updated_at, - merged_at = excluded.merged_at, - deleted_at = excluded.deleted_at - `), uuid.New().String(), wt.SessionID, wt.ID, wt.RepositoryID, wt.BranchSlug, 0, - wt.Path, wt.Branch, wt.Status, - wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt) - - return err -} - -// GetWorktreeByID retrieves a worktree by its unique ID. -func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree, error) { - wt := &Worktree{} - var mergedAt, deletedAt sql.NullTime - var repositoryPath, baseBranch sql.NullString - - err := s.ro.QueryRowContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - LEFT JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.worktree_id = ? - `), id).Scan( - &wt.ID, - &wt.SessionID, - &wt.TaskID, - &wt.RepositoryID, - &repositoryPath, - &wt.Path, - &wt.Branch, - &wt.BranchSlug, - &baseBranch, - &wt.Status, - &wt.CreatedAt, - &wt.UpdatedAt, - &mergedAt, - &deletedAt, - ) - - if err == sql.ErrNoRows { - return nil, nil // Not found, return nil without error - } - if err != nil { - return nil, err - } - - if repositoryPath.Valid { - wt.RepositoryPath = repositoryPath.String - } - if baseBranch.Valid { - wt.BaseBranch = baseBranch.String - } - if mergedAt.Valid { - wt.MergedAt = &mergedAt.Time - } - if deletedAt.Valid { - wt.DeletedAt = &deletedAt.Time - } - - return wt, nil -} - -func scanWorktreeRow(row *sql.Row) (*Worktree, error) { +// scanWorktreeRow scans one environment-repository row into a Worktree value. +func scanWorktreeRow(row rowScanner) (*Worktree, error) { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime var repositoryPath, baseBranch sql.NullString @@ -201,6 +80,7 @@ func scanWorktreeRow(row *sql.Row) (*Worktree, error) { &wt.UpdatedAt, &mergedAt, &deletedAt, + &wt.TaskEnvironmentID, ) if err == sql.ErrNoRows { return nil, nil @@ -225,28 +105,157 @@ func scanWorktreeRow(row *sql.Row) (*Worktree, error) { return wt, nil } +// resolveEnvironmentID maps a session to its task environment. Returns "" +// when the session has no environment yet (initial materialization happens +// before the environment row exists). +func (s *SQLiteStore) resolveEnvironmentID(ctx context.Context, sessionID string) (string, error) { + if sessionID == "" { + return "", nil + } + var envID string + err := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT COALESCE(task_environment_id, '') + FROM task_sessions WHERE id = ?`), sessionID).Scan(&envID) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + return envID, nil +} + +// CreateWorktree persists a worktree record under the session's task +// environment. The environment-repository row is the only physical-worktree +// record; multiple sessions sharing the environment observe the same rows. +// +// The insert serializes against the task cleanup barrier: PostgreSQL locks +// the owning task row and SQLite serializes the writer transaction, so a +// worktree admitted after archive/delete inventory capture is rejected with +// ErrTaskCleanupInProgress and the caller compensates the physical directory. +func (s *SQLiteStore) CreateWorktree(ctx context.Context, wt *Worktree) error { + if wt.ID == "" { + wt.ID = uuid.New().String() + } + if wt.SessionID == "" { + return fmt.Errorf("session ID is required to persist worktree") + } + envID, err := s.resolveEnvironmentID(ctx, wt.SessionID) + if err != nil { + return fmt.Errorf("resolve environment for worktree %s: %w", wt.ID, err) + } + if envID == "" { + return fmt.Errorf("%w: session %s", ErrEnvironmentNotResolved, wt.SessionID) + } + if wt.Status == "" { + wt.Status = StatusActive + } + now := time.Now().UTC() + if wt.CreatedAt.IsZero() { + wt.CreatedAt = now + } + if wt.UpdatedAt.IsZero() { + wt.UpdatedAt = now + } + wt.TaskEnvironmentID = envID + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var taskID string + if err := tx.QueryRowContext(ctx, s.db.Rebind(` + SELECT task_id FROM task_environments WHERE id = ?`), envID).Scan(&taskID); err != nil { + return fmt.Errorf("resolve worktree owner %s: %w", envID, err) + } + if err := s.checkTaskCleanupBarrierLocked(ctx, tx, taskID); err != nil { + return err + } + + _, err = tx.ExecContext(ctx, s.db.Rebind(` + INSERT INTO task_environment_repos ( + id, task_environment_id, repository_id, branch_slug, + worktree_id, worktree_path, worktree_branch, position, + error_message, status, created_at, updated_at, merged_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_environment_id, repository_id, branch_slug) DO UPDATE SET + worktree_id = excluded.worktree_id, + worktree_path = excluded.worktree_path, + worktree_branch = excluded.worktree_branch, + status = excluded.status, + updated_at = excluded.updated_at, + merged_at = excluded.merged_at, + deleted_at = excluded.deleted_at + `), uuid.New().String(), envID, wt.RepositoryID, wt.BranchSlug, + wt.ID, wt.Path, wt.Branch, 0, + "", wt.Status, + wt.CreatedAt, wt.UpdatedAt, wt.MergedAt, wt.DeletedAt) + if err != nil { + return err + } + return tx.Commit() +} + +// checkTaskCleanupBarrierLocked rejects the persistence when a task lifecycle +// cleanup barrier is active for the owning task. PostgreSQL serializes through +// a row lock on the task; SQLite's single-writer transaction is the lock. +func (s *SQLiteStore) checkTaskCleanupBarrierLocked(ctx context.Context, tx *sqlx.Tx, taskID string) error { + if dialect.IsPostgres(s.db.DriverName()) { + var lockedTaskID string + if err := tx.QueryRowContext(ctx, s.db.Rebind( + `SELECT id FROM tasks WHERE id = ? FOR UPDATE`, + ), taskID).Scan(&lockedTaskID); err != nil { + return fmt.Errorf("lock task for creation barrier: %w", err) + } + } + var active bool + if err := tx.QueryRowContext(ctx, s.db.Rebind(` + SELECT EXISTS ( + SELECT 1 FROM task_resource_cleanup_jobs + WHERE task_id = ? AND state IN (?, ?, ?, ?) + ) + `), taskID, + models.TaskResourceCleanupStatePrepared, + models.TaskResourceCleanupStatePending, + models.TaskResourceCleanupStateRunning, + models.TaskResourceCleanupStateRetryWait, + ).Scan(&active); err != nil { + return fmt.Errorf("check task cleanup barrier: %w", err) + } + if active { + return fmt.Errorf("%w: %s", ErrTaskCleanupInProgress, taskID) + } + return nil +} + +// GetWorktreeByID retrieves a worktree by its unique ID. +func (s *SQLiteStore) GetWorktreeByID(ctx context.Context, id string) (*Worktree, error) { + row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + LEFT JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE ter.worktree_id = ? + LIMIT 1 + `), id) + return scanWorktreeRow(row) +} + // GetWorktreeBySessionID retrieves the worktree by session ID. func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID string) (*Worktree, error) { row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.session_id = ? AND tsw.status = ? + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + INNER JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE s.id = ? AND ter.status = ? + AND ter.deleted_at IS NULL + ORDER BY ter.position ASC, ter.created_at ASC + LIMIT 1 `), sessionID, StatusActive) return scanWorktreeRow(row) } @@ -255,26 +264,14 @@ func (s *SQLiteStore) GetWorktreeBySessionID(ctx context.Context, sessionID stri // Since multiple worktrees can exist per task, this returns the most recently created active one. func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (*Worktree, error) { row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE s.task_id = ? AND tsw.status = ? - ORDER BY tsw.created_at DESC LIMIT 1 + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + LEFT JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE te.task_id = ? AND ter.status = ? + AND ter.deleted_at IS NULL + ORDER BY ter.created_at DESC LIMIT 1 `), taskID, StatusActive) return scanWorktreeRow(row) } @@ -282,26 +279,16 @@ func (s *SQLiteStore) GetWorktreeByTaskID(ctx context.Context, taskID string) (* // GetWorktreesByTaskID retrieves all worktrees for a task. func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ([]*Worktree, error) { rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE s.task_id = ? ORDER BY tsw.created_at DESC - `), taskID) + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + LEFT JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE te.task_id = ? + AND ter.deleted_at IS NULL + AND ter.status <> ? + ORDER BY ter.created_at DESC + `), taskID, StatusDeleted) if err != nil { return nil, err } @@ -313,26 +300,15 @@ func (s *SQLiteStore) GetWorktreesByTaskID(ctx context.Context, taskID string) ( // GetWorktreesByRepositoryID retrieves all worktrees for a repository. func (s *SQLiteStore) GetWorktreesByRepositoryID(ctx context.Context, repoID string) ([]*Worktree, error) { rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - LEFT JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.repository_id = ? - `), repoID) + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + LEFT JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE ter.repository_id = ? + AND ter.deleted_at IS NULL + AND ter.status <> ? + `), repoID, StatusDeleted) if err != nil { return nil, err } @@ -346,13 +322,12 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { wt.UpdatedAt = time.Now().UTC() query := ` - UPDATE task_session_worktrees SET - repository_id = ?, worktree_path = ?, worktree_branch = ?, + UPDATE task_environment_repos SET + worktree_path = ?, worktree_branch = ?, status = ?, updated_at = ?, merged_at = ?, deleted_at = ? WHERE worktree_id = ? ` args := []interface{}{ - wt.RepositoryID, wt.Path, wt.Branch, wt.Status, @@ -361,9 +336,9 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { wt.DeletedAt, wt.ID, } - if wt.SessionID != "" { - query += " AND session_id = ?" - args = append(args, wt.SessionID) + if wt.TaskEnvironmentID != "" { + query += " AND task_environment_id = ?" + args = append(args, wt.TaskEnvironmentID) } result, err := s.db.ExecContext(ctx, s.db.Rebind(query), args...) @@ -380,7 +355,7 @@ func (s *SQLiteStore) UpdateWorktree(ctx context.Context, wt *Worktree) error { // DeleteWorktree removes a worktree record. func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { - result, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM task_session_worktrees WHERE worktree_id = ?`), id) + result, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM task_environment_repos WHERE worktree_id = ?`), id) if err != nil { return err } @@ -395,25 +370,12 @@ func (s *SQLiteStore) DeleteWorktree(ctx context.Context, id string) error { // ListActiveWorktrees returns all worktrees with status 'active'. func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, error) { rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - LEFT JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.status = ? + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + LEFT JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE ter.status = ? AND ter.deleted_at IS NULL `), StatusActive) if err != nil { return nil, err @@ -424,14 +386,14 @@ func (s *SQLiteStore) ListActiveWorktrees(ctx context.Context) ([]*Worktree, err } // ListActiveWorktreePaths returns the worktree_path of every active, -// non-deleted task_session_worktrees row that has a non-empty path. The +// non-deleted environment-repository row that has a non-empty path. The // office GC uses this set as the authoritative inventory of live worktrees; // any directory under the worktree base that does not appear here (and is // older than the GC grace period) is considered orphaned. func (s *SQLiteStore) ListActiveWorktreePaths(ctx context.Context) ([]string, error) { rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` SELECT worktree_path - FROM task_session_worktrees + FROM task_environment_repos WHERE status = ? AND deleted_at IS NULL AND worktree_path <> '' @@ -452,9 +414,12 @@ func (s *SQLiteStore) ListActiveWorktreePaths(ctx context.Context) ([]string, er return paths, rows.Err() } -// CountActiveWorktreeReferences counts non-deleted worktree associations, -// excluding associations owned by the caller. A terminal session still protects -// its task workspace until that task's own cleanup releases the association. +// CountActiveWorktreeReferences counts non-deleted session associations from +// OTHER tasks for the environment that owns the worktree. The worktree is +// task-owned: sessions of the owning task (which share the environment) never +// count against cleanup, while a borrower task's session protects the +// workspace until ownership transfers. A terminal session still protects its +// task workspace until that task's own cleanup releases the association. func (s *SQLiteStore) CountActiveWorktreeReferences( ctx context.Context, worktreeID string, @@ -462,17 +427,22 @@ func (s *SQLiteStore) CountActiveWorktreeReferences( ) (int, error) { query := ` SELECT COUNT(*) - FROM task_session_worktrees tsw - WHERE tsw.worktree_id = ? - AND tsw.status <> ? - AND tsw.deleted_at IS NULL + FROM task_sessions s + INNER JOIN task_environment_repos ter + ON ter.task_environment_id = s.task_environment_id + INNER JOIN task_environments te + ON te.id = ter.task_environment_id + WHERE ter.worktree_id = ? + AND ter.status <> ? + AND ter.deleted_at IS NULL + AND te.task_id != s.task_id ` args := []interface{}{ worktreeID, StatusDeleted, } if len(excludeSessionIDs) > 0 { - query += ` AND tsw.session_id NOT IN (?)` + query += ` AND s.id NOT IN (?)` args = append(args, excludeSessionIDs) } query, args, err := sqlx.In(query, args...) @@ -484,9 +454,13 @@ func (s *SQLiteStore) CountActiveWorktreeReferences( return count, err } -// scanWorktrees is a helper to scan multiple worktree rows. +// scanWorktrees is a helper to scan multiple worktree rows. Task-keyed and +// repository-keyed queries LEFT JOIN sessions (a worktree belongs to the task +// environment, and a task may have zero or many sessions), so rows are +// deduplicated by worktree identity. func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { var result []*Worktree + seen := make(map[string]bool) for rows.Next() { wt := &Worktree{} var mergedAt, deletedAt sql.NullTime @@ -507,6 +481,7 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { &wt.UpdatedAt, &mergedAt, &deletedAt, + &wt.TaskEnvironmentID, ) if err != nil { return nil, err @@ -525,6 +500,10 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { wt.DeletedAt = &deletedAt.Time } + if seen[wt.ID] { + continue + } + seen[wt.ID] = true result = append(result, wt) } return result, rows.Err() @@ -534,26 +513,14 @@ func (s *SQLiteStore) scanWorktrees(rows *sql.Rows) ([]*Worktree, error) { // Implements MultiRepoStore. func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID string) ([]*Worktree, error) { rows, err := s.ro.QueryContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.session_id = ? AND tsw.status = ? - ORDER BY tsw.position ASC, tsw.created_at ASC + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + INNER JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE s.id = ? AND ter.status = ? + AND ter.deleted_at IS NULL + ORDER BY ter.position ASC, ter.created_at ASC `), sessionID, StatusActive) if err != nil { return nil, err @@ -571,27 +538,15 @@ func (s *SQLiteStore) GetWorktreesBySessionID(ctx context.Context, sessionID str // unchanged. Implements MultiRepoStore. func (s *SQLiteStore) GetWorktreeBySessionAndRepository(ctx context.Context, sessionID, repositoryID, branchSlug string) (*Worktree, error) { row := s.ro.QueryRowContext(ctx, s.ro.Rebind(` - SELECT - tsw.worktree_id, - tsw.session_id, - s.task_id, - tsw.repository_id, - r.local_path, - tsw.worktree_path, - tsw.worktree_branch, - COALESCE(tsw.branch_slug, ''), - s.base_branch, - tsw.status, - tsw.created_at, - tsw.updated_at, - tsw.merged_at, - tsw.deleted_at - FROM task_session_worktrees tsw - INNER JOIN task_sessions s ON tsw.session_id = s.id - LEFT JOIN repositories r ON tsw.repository_id = r.id - WHERE tsw.session_id = ? AND tsw.repository_id = ? - AND COALESCE(tsw.branch_slug, '') = ? - AND tsw.status = ? + SELECT `+worktreeSelectCols+` + FROM task_environment_repos ter + INNER JOIN task_environments te ON ter.task_environment_id = te.id + INNER JOIN task_sessions s ON s.task_environment_id = ter.task_environment_id + LEFT JOIN repositories r ON ter.repository_id = r.id + WHERE s.id = ? AND ter.repository_id = ? + AND COALESCE(ter.branch_slug, '') = ? + AND ter.status = ? + AND ter.deleted_at IS NULL LIMIT 1 `), sessionID, repositoryID, branchSlug, StatusActive) return scanWorktreeRow(row) diff --git a/apps/backend/internal/worktree/store_test.go b/apps/backend/internal/worktree/store_test.go index 7139f7319e..5b4e159a54 100644 --- a/apps/backend/internal/worktree/store_test.go +++ b/apps/backend/internal/worktree/store_test.go @@ -14,16 +14,21 @@ import ( tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" ) -// newTestStore opens an in-memory SQLite DB and constructs a *SQLiteStore on it. -// initSchema runs inside NewSQLiteStore so the table is ready. +// newTestStore opens a SQLite DB with the task repository schema (which owns +// the task_environment_repos tables the store reads and writes) and +// constructs a *SQLiteStore on it. func newTestStore(t *testing.T) *SQLiteStore { t.Helper() - db, err := sqlx.Open("sqlite3", ":memory:") + dbConn, err := dbutil.OpenSQLite(filepath.Join(t.TempDir(), "store.db")) if err != nil { t.Fatalf("open sqlite: %v", err) } + db := sqlx.NewDb(dbConn, "sqlite3") t.Cleanup(func() { _ = db.Close() }) + if _, err := tasksqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init task schema: %v", err) + } store, err := NewSQLiteStore(db, db) if err != nil { t.Fatalf("new store: %v", err) @@ -31,6 +36,32 @@ func newTestStore(t *testing.T) *SQLiteStore { return store } +// seedSessionWithEnvironment creates a task, an environment, and a session +// linked to that environment so worktree records can be persisted. +func (s *SQLiteStore) seedSessionWithEnvironment(t *testing.T, sessionID, taskID string) { + t.Helper() + ctx := context.Background() + envID := "env-" + sessionID + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, 'workspace', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, taskID, taskID); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO task_environments (id, task_id, executor_type, status, workspace_path, created_at, updated_at) + VALUES (?, ?, 'worktree', 'ready', '/tmp/' || ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, envID, taskID, taskID); err != nil { + t.Fatalf("seed environment: %v", err) + } + if _, err := s.db.ExecContext(ctx, ` + INSERT INTO task_sessions (id, task_id, state, task_environment_id, started_at, updated_at) + VALUES (?, ?, 'COMPLETED', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, sessionID, taskID, envID); err != nil { + t.Fatalf("seed session: %v", err) + } +} + func TestSQLiteStore_ReinitializesSchema(t *testing.T) { dbConn, err := dbutil.OpenSQLite(filepath.Join(t.TempDir(), "worktree-replay.db")) if err != nil { @@ -59,15 +90,16 @@ func TestSQLiteStore_ListActiveWorktreePaths(t *testing.T) { now := time.Now().UTC() - mustCreate := func(wt *Worktree) { + mustCreate := func(sessionID, taskID string, wt *Worktree) { t.Helper() + store.seedSessionWithEnvironment(t, sessionID, taskID) if err := store.CreateWorktree(ctx, wt); err != nil { t.Fatalf("create %s: %v", wt.ID, err) } } // Active worktree with a real path — should appear. - mustCreate(&Worktree{ + mustCreate("sess-1", "task-1", &Worktree{ ID: "wt-active-1", SessionID: "sess-1", RepositoryID: "repo-1", @@ -80,7 +112,7 @@ func TestSQLiteStore_ListActiveWorktreePaths(t *testing.T) { // Active worktree with empty path — must be filtered out (the GC // can't act on an empty path anyway, and the SQL guard prevents // accidental wildcard matches). - mustCreate(&Worktree{ + mustCreate("sess-2", "task-2", &Worktree{ ID: "wt-active-empty", SessionID: "sess-2", RepositoryID: "repo-2", @@ -92,7 +124,7 @@ func TestSQLiteStore_ListActiveWorktreePaths(t *testing.T) { // "Deleted" status worktree — must be filtered out. deletedAt := now - mustCreate(&Worktree{ + mustCreate("sess-3", "task-3", &Worktree{ ID: "wt-deleted", SessionID: "sess-3", RepositoryID: "repo-3", @@ -104,7 +136,7 @@ func TestSQLiteStore_ListActiveWorktreePaths(t *testing.T) { }) // Second active worktree to confirm ordering doesn't matter. - mustCreate(&Worktree{ + mustCreate("sess-4", "task-4", &Worktree{ ID: "wt-active-2", SessionID: "sess-4", RepositoryID: "repo-4", diff --git a/apps/backend/internal/worktree/worktree.go b/apps/backend/internal/worktree/worktree.go index d26f5025cf..49cf0a46a2 100644 --- a/apps/backend/internal/worktree/worktree.go +++ b/apps/backend/internal/worktree/worktree.go @@ -38,12 +38,17 @@ type Worktree struct { // Multiple worktrees can exist for the same task (one per agent session). TaskID string `json:"task_id"` + // TaskEnvironmentID is the task environment that owns this worktree. + // Physical worktree records live on task_environment_repos; sessions + // reference the environment through task_sessions.task_environment_id. + TaskEnvironmentID string `json:"task_environment_id,omitempty"` + // RepositoryID is the ID of the repository this worktree belongs to. RepositoryID string `json:"repository_id"` // BranchSlug, when set, disambiguates two worktrees that share a // (SessionID, RepositoryID) pair on different branches. Stored on - // task_session_worktrees so reuse lookups can scope by branch and not + // task_environment_repos so reuse lookups can scope by branch and not // collapse multi-branch tasks down to a single worktree. BranchSlug string `json:"branch_slug,omitempty"` @@ -127,6 +132,12 @@ type CreateRequest struct { // SessionID is the task session identifier (required for persistence). SessionID string + // TaskEnvironmentID, when known, is the environment that owns the + // worktree. The store falls back to resolving it from SessionID. During + // initial launch materialization the environment does not exist yet and + // persistence is deferred to the executor's environment transaction. + TaskEnvironmentID string + // TaskTitle is the human-readable task title (optional). // If provided, it will be used to generate semantic worktree/branch names. // The title is sanitized and truncated to 20 characters. @@ -206,7 +217,7 @@ type CreateRequest struct { BranchSlug string // BranchIdentitySlug, when non-empty, is the stable branch key used for - // reuse lookup, cache keys, and persisted task_session_worktrees rows. It + // reuse lookup, cache keys, and persisted task_environment_repos rows. It // may differ from BranchSlug when the primary branch keeps the flat path. BranchIdentitySlug string diff --git a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx index 56ea789e57..a1d46e8a37 100644 --- a/apps/web/components/task/mobile/mobile-sessions-section.test.tsx +++ b/apps/web/components/task/mobile/mobile-sessions-section.test.tsx @@ -26,6 +26,7 @@ vi.mock("@/components/state-provider", () => ({ kanban: { tasks: [{ id: "task-1", primarySessionId: "session-a" }] }, repositories: { itemsByWorkspaceId: mocks.repositoriesByWorkspaceId }, messages: { bySession: mocks.messagesBySession }, + executors: { items: [] }, setActiveSession: mocks.setActiveSession, }), })); @@ -44,7 +45,7 @@ vi.mock("@/hooks/domains/session/use-session-actions", () => ({ remove: vi.fn(), }), isSessionStoppable: () => false, - isSessionDeletable: () => false, + isSessionDeletable: () => true, isSessionResumable: () => false, })); @@ -343,3 +344,26 @@ describe("MobileSessionsPicker pending lifecycle", () => { mocks.messagesBySession = {}; }); }); + +describe("MobileSessionsPicker session delete confirmation", () => { + it("states conversation deletion and workspace retention from the actions sheet", () => { + mocks.sessions = [session(SESSION_A, "profile-a", START_TIME, { state: "COMPLETED" })]; + render(); + + // Open the picker sheet, then the session's actions menu. + fireEvent.click(screen.getByTestId(PILL_TESTID)); + const dotsButton = screen.getByRole("button", { name: "Session actions" }); + fireEvent.pointerDown(dotsButton); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + + const dialog = screen.getByRole("alertdialog"); + expect(within(dialog).getByText(/permanently delete the conversation history/i)).toBeTruthy(); + expect( + within(dialog).getAllByText(/task workspace and its files are kept/i).length, + ).toBeGreaterThan(0); + expect(within(dialog).getByText(/only session for this task/i)).toBeTruthy(); + // No uncommitted/unpushed warning belongs on session deletion. + expect(within(dialog).queryByText(/uncommitted/i)).toBeNull(); + expect(within(dialog).queryByText(/unpushed/i)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/session-tab-menu.test.tsx b/apps/web/components/task/session-tab-menu.test.tsx new file mode 100644 index 0000000000..1b9054332c --- /dev/null +++ b/apps/web/components/task/session-tab-menu.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { DeleteSessionDialog } from "./session-tab-menu"; + +describe("DeleteSessionDialog", () => { + it("states conversation deletion and workspace retention", () => { + render( + , + ); + + const dialog = screen.getByRole("alertdialog"); + expect(dialog.textContent).toContain("permanently delete the conversation history"); + expect(dialog.textContent).toContain("task workspace and its files are kept"); + expect(dialog.textContent).toContain("only session for this task"); + // No uncommitted/unpushed warning belongs on session deletion. + expect(dialog.textContent).not.toContain("uncommitted"); + expect(dialog.textContent).not.toContain("unpushed"); + }); + + it("confirms deletion when the destructive action is activated", () => { + const onConfirm = vi.fn(); + const onOpenChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 8906dc5ef4..da5c1211cc 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -2063,6 +2063,13 @@ export class ApiClient { worktree_path?: string; workspace_path?: string; status: string; + repos?: Array<{ + repository_id?: string; + worktree_id?: string; + worktree_path?: string; + worktree_branch?: string; + status?: string; + }>; } | null> { const res = await this.rawRequest("GET", `/api/v1/tasks/${taskId}/environment`); if (res.status === 404) return null; diff --git a/apps/web/e2e/tests/office/agent-launch-context.spec.ts b/apps/web/e2e/tests/office/agent-launch-context.spec.ts index 19a9f3c350..068cd93587 100644 --- a/apps/web/e2e/tests/office/agent-launch-context.spec.ts +++ b/apps/web/e2e/tests/office/agent-launch-context.spec.ts @@ -71,7 +71,7 @@ test.describe("Office agent launch context", () => { .poll( async () => { const env = await apiClient.getTaskEnvironment(task.id); - worktreePath = env?.workspace_path ?? env?.worktree_path ?? ""; + worktreePath = env?.workspace_path ?? env?.repos?.[0]?.worktree_path ?? ""; return worktreePath; }, { timeout: 30_000, message: "task environment workspace_path never appeared" }, diff --git a/apps/web/e2e/tests/office/skill-injection.spec.ts b/apps/web/e2e/tests/office/skill-injection.spec.ts index 9f932464f1..9ed319881f 100644 --- a/apps/web/e2e/tests/office/skill-injection.spec.ts +++ b/apps/web/e2e/tests/office/skill-injection.spec.ts @@ -57,7 +57,7 @@ test("skill injection writes assigned skill to session worktree on launch", asyn .poll( async () => { const env = await apiClient.getTaskEnvironment(task.id); - worktreePath = env?.workspace_path ?? env?.worktree_path ?? ""; + worktreePath = env?.workspace_path ?? env?.repos?.[0]?.worktree_path ?? ""; return worktreePath; }, { timeout: 30_000, message: "task environment workspace_path never appeared" }, diff --git a/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts b/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts index b004dc7212..7d88875269 100644 --- a/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts +++ b/apps/web/e2e/tests/session/mobile-session-deletion.spec.ts @@ -69,6 +69,10 @@ test.describe("mobile: session deletion", () => { const dialog = testPage.getByRole("alertdialog"); await expect(dialog).toBeVisible(); + // The native mobile dialog states the conversation-deletion contract and + // explicitly says the task workspace and files are retained. + await expect(dialog).toContainText("permanently delete the conversation history"); + await expect(dialog).toContainText("task workspace and its files are kept"); await dialog.getByRole("button", { name: "Delete" }).tap(); await expect(secondaryRow).not.toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/session/multi-session-ux.spec.ts b/apps/web/e2e/tests/session/multi-session-ux.spec.ts index e98da0757b..8dbbfce427 100644 --- a/apps/web/e2e/tests/session/multi-session-ux.spec.ts +++ b/apps/web/e2e/tests/session/multi-session-ux.spec.ts @@ -1,7 +1,13 @@ +import { existsSync } from "node:fs"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; +const fsExists = (p: string) => existsSync(p); + const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; /** @@ -481,3 +487,98 @@ test.describe("Multi-session UX", () => { }); }); }); + +test.describe("Session deletion preserves the task workspace", () => { + test("deleting the only session keeps the worktree and a replacement session reuses it", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + + const { task, session } = await createTaskAndNavigate( + testPage, + apiClient, + seedData, + "Preserve Workspace Task", + ); + + // The worktree-mode environment exposes the on-disk workspace root. + await expect + .poll( + async () => { + const env = await apiClient.getTaskEnvironment(task.id); + return env?.workspace_path ?? ""; + }, + { timeout: 30_000, message: "Waiting for task environment workspace path" }, + ) + .not.toBe(""); + + const env = await apiClient.getTaskEnvironment(task.id); + const workspacePath = env?.workspace_path ?? ""; + if (!workspacePath) throw new Error("no workspace path for the task environment"); + const environmentId = env?.id ?? ""; + + // Write an uncommitted marker into the worktree. + const markerPath = join(workspacePath, "uncommitted-marker.txt"); + writeFileSync(markerPath, "keep-me\n", "utf8"); + await expect(fsExists(markerPath)).toBe(true); + + const { sessions: before } = await apiClient.listTaskSessions(task.id); + const onlySessionId = before[0]?.id; + if (!onlySessionId) throw new Error("expected exactly one session"); + + // Delete the only session through the visible UI. + const tab = session.sessionTabBySessionId(onlySessionId); + await expect(tab).toBeVisible({ timeout: 10_000 }); + await tab.click({ button: "right" }); + await session.contextMenuItem("Delete").click(); + const dialog = session.alertDialog(); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText("task workspace and its files are kept"); + await dialog.getByRole("button", { name: "Delete" }).click(); + + // The task workspace and its uncommitted marker survive session deletion. + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return !sessions.some((s) => s.id === onlySessionId); + }, + { timeout: 15_000, message: "Waiting for the only session to be deleted" }, + ) + .toBe(true); + await expect(fsExists(markerPath)).toBe(true); + const envAfter = await apiClient.getTaskEnvironment(task.id); + expect(envAfter?.id).toBe(environmentId); + + // A replacement session reuses the retained workspace and observes the + // marker. (The task page auto-ensures a session when the task has none; + // the explicit dialog still runs the replacement prompt deterministically.) + await session.openNewSessionDialog(); + await session.newSessionPromptInput().fill("/e2e:simple-message"); + await session.newSessionStartButton().click(); + await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 }); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.filter((s) => DONE_STATES.includes(s.state)).length; + }, + { timeout: 60_000, message: "Waiting for replacement session to finish" }, + ) + .toBeGreaterThan(0); + + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + + // The replacement session reused the retained environment, not a fresh one. + const { sessions: after } = await apiClient.listTaskSessions(task.id); + expect(after.some((s) => s.task_environment_id === environmentId)).toBe(true); + const envAfterReplacement = await apiClient.getTaskEnvironment(task.id); + expect(envAfterReplacement?.workspace_path).toBe(workspacePath); + await expect(fsExists(markerPath)).toBe(true); + }); +}); diff --git a/apps/web/e2e/tests/settings/copy-files-symlink.spec.ts b/apps/web/e2e/tests/settings/copy-files-symlink.spec.ts index 9b8aa95479..078f1ab3dd 100644 --- a/apps/web/e2e/tests/settings/copy-files-symlink.spec.ts +++ b/apps/web/e2e/tests/settings/copy-files-symlink.spec.ts @@ -27,7 +27,7 @@ test("configures symlink mode and creates a task", async ({ .poll( async () => { const environment = await apiClient.getTaskEnvironment(taskId); - const worktreePath = environment?.worktree_path; + const worktreePath = environment?.repos?.[0]?.worktree_path; if (!worktreePath) return false; const linkPath = path.join(worktreePath, ".env"); if (!fs.existsSync(linkPath)) return false; diff --git a/apps/web/e2e/tests/settings/mobile-copy-files-symlink.spec.ts b/apps/web/e2e/tests/settings/mobile-copy-files-symlink.spec.ts index 27e0518dfc..42837fb6b5 100644 --- a/apps/web/e2e/tests/settings/mobile-copy-files-symlink.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-copy-files-symlink.spec.ts @@ -27,7 +27,7 @@ test("configures symlink mode and creates a task on mobile", async ({ .poll( async () => { const environment = await apiClient.getTaskEnvironment(taskId); - const worktreePath = environment?.worktree_path; + const worktreePath = environment?.repos?.[0]?.worktree_path; if (!worktreePath) return false; const linkPath = path.join(worktreePath, ".env"); if (!fs.existsSync(linkPath)) return false; diff --git a/apps/web/e2e/tests/task/task-environment.spec.ts b/apps/web/e2e/tests/task/task-environment.spec.ts index 69219d9c4b..462f18de7f 100644 --- a/apps/web/e2e/tests/task/task-environment.spec.ts +++ b/apps/web/e2e/tests/task/task-environment.spec.ts @@ -117,9 +117,10 @@ test.describe("Task environment", () => { expect(env).not.toBeNull(); const { sessions } = await apiClient.listTaskSessions(task.id); - // If the session has a worktree_path, it should match the environment's worktree + // If the session has a worktree_path, it should match the environment's + // repository worktree row. if (sessions[0].worktree_path) { - expect(env!.worktree_path).toBe(sessions[0].worktree_path); + expect(env!.repos?.[0]?.worktree_path).toBe(sessions[0].worktree_path); } }); }); diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index d6bae673b5..bc9bd827bb 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1304,7 +1304,7 @@ "thinking": "Thinking", "thisConversationHasNoShareableContent": "This conversation has no shareable content.", "thisExecutor": "this executor", - "thisIsTheOnlySessionFor": "This is the only session for this task.", + "thisIsTheOnlySessionFor": "This is the only session for this task, but the task workspace and its files are kept for a future session.", "thisIsThePrimarySessionAnother": "This is the primary session. Another session will be set as primary.", "thisRepositoryMustHaveACloneable": "This repository must have a cloneable origin; Kandev will verify it before adding.", "thisRestartsTheTaskWorkspace": "This restarts the task workspace", @@ -1322,7 +1322,7 @@ "thisUpdatesTheLiveTaskWorkspace": "This updates the live task workspace", "thisWillClearTheAgentS": "This will clear the agent's conversation history and start a fresh context. Your workspace, files, and git state will be preserved.", "thisWillPermanentlyDeleteSelectedItems": "This will permanently delete {{selectedCount}} selected items. This action cannot be undone.", - "thisWillPermanentlyDeleteTheConversation": "This will permanently delete the conversation history with this session.", + "thisWillPermanentlyDeleteTheConversation": "This will permanently delete the conversation history with this session. The task workspace and its files are kept.", "ticket": "Ticket", "timedOutAgentMovedOn": "Timed out (agent moved on)", "title": "title", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index e4f17bb7e4..6a9409fd43 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -867,7 +867,7 @@ "pluginTopBarActions": "Ƥĺũĝĩń ţōƥ ƀàŕ àćţĩōńś", "portAlreadyAdded": "Ƥōŕţ àĺŕēàďŷ àďďēď", "portForwarding": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ", - "portForwardingPreferenceUpdateFailed": "Ćōũĺď ńōţ ũƥďàţē ƥōŕţ Ƒōŕŵàŕďĩńĝ vĩśĩƀĩĺĩţŷ", + "portForwardingPreferenceUpdateFailed": "Ćōũĺď ńōţ ũƥďàţē ƥōŕţ ƒōŕŵàŕďĩńĝ vĩśĩƀĩĺĩţŷ", "portForwardingTunnelActive_one": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ ({{count}} ţũńńēĺ àćţĩvē)", "portForwardingTunnelActive_other": "Ƥōŕţ Ƒōŕŵàŕďĩńĝ ({{count}} ţũńńēĺś àćţĩvē)", "portNumber": "Ƥōŕţ ńũḿƀēŕ", @@ -1304,7 +1304,7 @@ "thinking": "Ţĥĩńķĩńĝ", "thisConversationHasNoShareableContent": "Ţĥĩś ćōńvēŕśàţĩōń ĥàś ńō śĥàŕēàƀĺē ćōńţēńţ.", "thisExecutor": "ţĥĩś ēxēćũţōŕ", - "thisIsTheOnlySessionFor": "Ţĥĩś ĩś ţĥē ōńĺŷ śēśśĩōń ƒōŕ ţĥĩś ţàśķ.", + "thisIsTheOnlySessionFor": "Ţĥĩś ĩś ţĥē ōńĺŷ śēśśĩōń ƒōŕ ţĥĩś ţàśķ, ƀũţ ţĥē ţàśķ ŵōŕķśƥàćē àńď ĩţś ƒĩĺēś àŕē ķēƥţ ƒōŕ à ƒũţũŕē śēśśĩōń.", "thisIsThePrimarySessionAnother": "Ţĥĩś ĩś ţĥē ƥŕĩḿàŕŷ śēśśĩōń. Àńōţĥēŕ śēśśĩōń ŵĩĺĺ ƀē śēţ àś ƥŕĩḿàŕŷ.", "thisRepositoryMustHaveACloneable": "Ţĥĩś ŕēƥōśĩţōŕŷ ḿũśţ ĥàvē à ćĺōńēàƀĺē ōŕĩĝĩń; Ķàńďēv ŵĩĺĺ vēŕĩƒŷ ĩţ ƀēƒōŕē àďďĩńĝ.", "thisRestartsTheTaskWorkspace": "Ţĥĩś ŕēśţàŕţś ţĥē ţàśķ ŵōŕķśƥàćē", @@ -1322,7 +1322,7 @@ "thisUpdatesTheLiveTaskWorkspace": "Ţĥĩś ũƥďàţēś ţĥē ĺĩvē ţàśķ ŵōŕķśƥàćē", "thisWillClearTheAgentS": "Ţĥĩś ŵĩĺĺ ćĺēàŕ ţĥē àĝēńţ'ś ćōńvēŕśàţĩōń ĥĩśţōŕŷ àńď śţàŕţ à ƒŕēśĥ ćōńţēxţ. Ŷōũŕ ŵōŕķśƥàćē, ƒĩĺēś, àńď ĝĩţ śţàţē ŵĩĺĺ ƀē ƥŕēśēŕvēď.", "thisWillPermanentlyDeleteSelectedItems": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē {{selectedCount}} śēĺēćţēď ĩţēḿś. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.", - "thisWillPermanentlyDeleteTheConversation": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē ţĥē ćōńvēŕśàţĩōń ĥĩśţōŕŷ ŵĩţĥ ţĥĩś śēśśĩōń.", + "thisWillPermanentlyDeleteTheConversation": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē ţĥē ćōńvēŕśàţĩōń ĥĩśţōŕŷ ŵĩţĥ ţĥĩś śēśśĩōń. Ţĥē ţàśķ ŵōŕķśƥàćē àńď ĩţś ƒĩĺēś àŕē ķēƥţ.", "ticket": "Ţĩćķēţ", "timedOutAgentMovedOn": "Ţĩḿēď ōũţ (àĝēńţ ḿōvēď ōń)", "title": "ţĩţĺē", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 860eed0634..067a3ee7a3 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1303,7 +1303,7 @@ "thinking": "A pensar", "thisConversationHasNoShareableContent": "Esta conversa não tem conteúdo partilhável.", "thisExecutor": "este executor", - "thisIsTheOnlySessionFor": "Esta é a única sessão desta tarefa.", + "thisIsTheOnlySessionFor": "Esta é a única sessão desta tarefa, mas o espaço de trabalho e os seus ficheiros são mantidos para uma futura sessão.", "thisIsThePrimarySessionAnother": "Esta é a sessão principal. Outra sessão será definida como principal.", "thisRepositoryMustHaveACloneable": "Este repositório tem de ter uma origem clonável; o Kandev verifica-a antes de o adicionar.", "thisRestartsTheTaskWorkspace": "Isto reinicia a área de trabalho da tarefa", @@ -1321,7 +1321,7 @@ "thisUpdatesTheLiveTaskWorkspace": "Isto atualiza a área de trabalho da tarefa em uso", "thisWillClearTheAgentS": "Isto limpa o histórico da conversa do agente e inicia um contexto novo. A sua área de trabalho, os ficheiros e o estado do Git são preservados.", "thisWillPermanentlyDeleteSelectedItems": "Isto elimina permanentemente {{selectedCount}} itens selecionados. Esta ação não pode ser anulada.", - "thisWillPermanentlyDeleteTheConversation": "Isto elimina permanentemente o histórico da conversa desta sessão.", + "thisWillPermanentlyDeleteTheConversation": "Isto elimina permanentemente o histórico da conversa desta sessão. O espaço de trabalho da tarefa e os seus ficheiros são mantidos.", "ticket": "Ticket", "timedOutAgentMovedOn": "Tempo esgotado (o agente avançou)", "title": "título", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 550038235c..deb9ce7b91 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1291,7 +1291,7 @@ "thinking": "思考中", "thisConversationHasNoShareableContent": "此对话没有可分享的内容。", "thisExecutor": "此执行器", - "thisIsTheOnlySessionFor": "这是此任务的唯一会话。", + "thisIsTheOnlySessionFor": "这是此任务的唯一会话,但任务工作区和其中的文件会保留给未来的会话。", "thisIsThePrimarySessionAnother": "这是主会话。将有另一个会话被设为主会话。", "thisRepositoryMustHaveACloneable": "此仓库必须具有可克隆的 origin;Kandev 会在添加前进行验证。", "thisRestartsTheTaskWorkspace": "这将重启任务工作区", @@ -1309,7 +1309,7 @@ "thisUpdatesTheLiveTaskWorkspace": "这将更新活动的任务工作区", "thisWillClearTheAgentS": "这将清除代理的对话历史并开始全新上下文。你的工作区、文件与 git 状态会保留。", "thisWillPermanentlyDeleteSelectedItems": "将永久删除 {{selectedCount}} 个所选项目。此操作无法撤销。", - "thisWillPermanentlyDeleteTheConversation": "这将永久删除与此会话的对话历史。", + "thisWillPermanentlyDeleteTheConversation": "这将永久删除与此会话的对话历史。任务工作区和其中的文件会被保留。", "ticket": "工单", "timedOutAgentMovedOn": "已超时(代理已继续)", "title": "标题", diff --git a/docs/decisions/0004-task-model-unification.md b/docs/decisions/0004-task-model-unification.md index 17ddc61f15..fb96042aa9 100644 --- a/docs/decisions/0004-task-model-unification.md +++ b/docs/decisions/0004-task-model-unification.md @@ -43,7 +43,7 @@ type Runtime interface { `LaunchSpec` carries everything the runtime needs to run an agent: profile, executor, workspace, prompt, prior ACP session id (for resume), MCP mode, metadata. The runtime knows nothing about tasks, workflows, or office stages. Both the workflow engine and (separately) cron-driven trigger handlers call `Launch`. -The runtime owns the *execution* tables: `agent_executions` (renamed `executors_running`), the message and turn family that's currently `task_session_messages` / `task_session_turns`, and `task_session_worktrees`. These are generic — they describe an agent conversation, not a task lifecycle. +The runtime owns the *execution* tables: `agent_executions` (renamed `executors_running`) and the message and turn family that's currently `task_session_messages` / `task_session_turns`. These are generic — they describe an agent conversation, not a task lifecycle. Physical worktrees are task-environment resources under [ADR-2026-08-08-task-owned-worktree-lifetime](2026-08-08-task-owned-worktree-lifetime.md), not session execution records. The naming "runtime" is concrete: it's the runtime layer for agents, sitting under the workflow engine and atop agentctl + executor backends. Replaces the earlier draft's "kernel" (too generic). diff --git a/docs/decisions/0009-fail-closed-gc-semantics.md b/docs/decisions/0009-fail-closed-gc-semantics.md index 4746dbe8aa..c0c7d3dd9f 100644 --- a/docs/decisions/0009-fail-closed-gc-semantics.md +++ b/docs/decisions/0009-fail-closed-gc-semantics.md @@ -4,6 +4,9 @@ **Date:** 2026-05-16 **Area:** backend +**Amended:** 2026-08-08 by +[ADR-2026-08-08-task-owned-worktree-lifetime](2026-08-08-task-owned-worktree-lifetime.md) + ## Context Kandev's office service runs a background garbage collector that, every three hours, walks `~/.kandev/tasks/` and removes "orphaned" worktree directories. It also walks kandev-labeled Docker containers and removes ones whose tasks are gone or terminal. The intent was good — agents crash, leave state behind, and disks fill. The implementation was not. @@ -20,7 +23,7 @@ The bug was not "wrong table." It was "destructive action on a negative signal." Concretely: -1. **Authoritative inventory first.** Each sweep starts by querying the source of truth for what is alive: `task_session_worktrees.worktree_path` for worktrees, `tasks` for containers. Anything not in the inventory is a *candidate* for deletion, not yet condemned. +1. **Authoritative inventory first.** Each sweep starts by querying the source of truth for what is alive: `task_environment_repos.worktree_path` through its owning `task_environments` row for worktrees, and `tasks` for containers. Anything not in the inventory is a *candidate* for deletion, not yet condemned. 2. **Fail-closed on error.** A failure to read the inventory — query error, closed pool, missing dependency — aborts the sweep with a logged warning. The GC never proceeds with an empty or partial inventory. diff --git a/docs/decisions/2026-08-08-task-owned-worktree-lifetime.md b/docs/decisions/2026-08-08-task-owned-worktree-lifetime.md new file mode 100644 index 0000000000..7858de9c1b --- /dev/null +++ b/docs/decisions/2026-08-08-task-owned-worktree-lifetime.md @@ -0,0 +1,135 @@ +# ADR-2026-08-08-task-owned-worktree-lifetime: Keep Worktree Ownership at the Task Lifecycle + +**Status:** accepted +**Date:** 2026-08-08 +**Area:** backend, frontend + +## Context + +A `TaskEnvironment` is task-owned and may be reused by multiple sessions or +borrowed by another task. The existing `task_session_worktrees` row combines a +session reference with the only durable physical worktree pointer and cascades +when the session is deleted. PR #2421 attempted to compensate by snapshotting +that row and reclaiming the worktree when no session references remained, but +zero session references does not mean the task has relinquished its workspace. + +## Decision + +Use the existing task-environment model as the only workspace ownership graph. +`task_environments.task_id` owns the environment, and one +`task_environment_repos` row owns each materialized repository worktree. A +session refers to the workspace only through +`task_sessions.task_environment_id`; no session-to-worktree table is needed. + +Normalize the schema in one validated upgrade. Backfill +`task_environment_repos` from its existing rows, the deprecated flat worktree +columns on `task_environments`, and `task_session_worktrees`. Create a normalized +environment for a legacy worktree group only when no valid environment exists, +and update the affected sessions to reference it. After validation proves that +every persisted worktree has exactly one task-environment owner, drop +`task_session_worktrees` and the deprecated `task_environments.repository_id`, +`worktree_id`, `worktree_path`, and `worktree_branch` columns. Fresh databases +are created directly in the final shape. Runtime code has no dual-read or +dual-write compatibility path; only the versioned migration knows the legacy +schema. + +Before the durable cleanup worker starts, the upgrade also removes any +preview-build `task_resource_cleanup_jobs` rows whose trigger is +`session_delete`. That trigger is invalid under the ownership model and its code +identity is removed; task-level cleanup jobs and their history remain intact. + +The cutover uses a dedicated, error-returning migration rather than +`db.MigrateLogger.Apply`, whose legacy contract logs and swallows unexpected +errors. The migration acquires the database writer/migration lock and performs +the following in one transaction: + +1. Read and normalize all legacy ownership rows into shadow tables. +2. Compare the complete legacy and normalized worktree identity/path/branch sets. +3. Verify that every session that previously referenced a worktree resolves to + one valid environment and that every physical worktree has one canonical + environment-repository owner. +4. Verify uniqueness, foreign keys, row counts, and the exact final schema. +5. Only then drop/rename the legacy tables and columns and commit. + +Any query, copy, validation, DDL, or commit error rolls the transaction back. +SQLite uses the existing fatal pre-upgrade `VACUUM INTO` snapshot in addition to +transaction rollback. PostgreSQL uses transactional DDL under a migration +advisory lock and takes exclusive locks on every affected ownership table; lock +timeout aborts without mutation. PostgreSQL operators retain their existing +external backup policy and must stop mixed-version writers during the upgrade. +The migration never calls filesystem or Git cleanup, and the cleanup worker does +not start until repository initialization succeeds. + +`task_environment_repos` remains because it models a real domain concept: the +ordered repositories in an environment, including preparation failures where no +worktree exists. Its worktree identity, path, branch, status, and lifecycle +timestamps become the single source of physical-worktree truth. Environment API +responses expose the repository collection instead of deprecated flat worktree +fields. + +Only task lifecycle operations may remove physical workspace resources. Archive, +delete, cascade, workspace delete, quick-chat expiry, and explicit environment +reset use the existing durable `task_resource_cleanup_jobs` worker. Task cleanup +inventories `task_environment_repos` by joining through +`task_environments.task_id`, snapshots handles before destructive task-row +mutation, preserves shared environments, and retries filesystem/Git failures +after restart. + +Reserve the prepared task cleanup job before taking its inventory. Session and +worktree creation serialize on the task row and refuse creation while that +prepared lifecycle barrier is active. The barrier transaction commits before +the cleanup worker takes repository or filesystem locks, preserving the existing +Git lock order and avoiding a database/Git lock inversion. + +## Consequences + +- A task can have zero sessions without losing its workspace, Git registration, + branch, or uncommitted files. +- Session deletion becomes a pure conversation lifecycle operation; + no `session_delete` cleanup trigger or `ReclaimSessionWorktree` path exists. +- SQLite and PostgreSQL need transactional, replayable normalization migrations + that either reach the final schema completely or leave the old database + untouched. +- Invalid or ambiguous legacy data causes a diagnostic startup refusal. It does + not trigger a partial repair or destructive best-effort conversion. +- The final schema is intentionally incompatible with older binaries. SQLite + downgrade requires restoring the automatic pre-upgrade snapshot; PostgreSQL + downgrade requires restoring the operator's pre-upgrade backup. There is no + rolling mixed-version deployment window. +- `task_session_worktrees`, its CRUD/model surface, duplicate worktree fields, + and runtime compatibility branches are removed after backfill. +- Worktree-store reads, session projections, storage inventory, and task cleanup + all use the task environment and its repository rows. +- Shared-environment ownership transfer moves the environment's `task_id`; its + worktrees follow automatically without a second ownership update. +- Creation paths must compensate a physical worktree if the canonical owner and + environment-repository transaction is rejected by a concurrent task lifecycle + barrier. + +## Alternatives Considered + +1. **Reclaim when the final session reference disappears.** Rejected because + reference count is not ownership and a task intentionally remains reusable + with zero sessions. +2. **Keep only a session-delete cleanup snapshot.** Rejected because it preserves + a pointer solely to destroy the resource and still cannot represent the task's + continuing ownership. +3. **Add `task_worktrees` and retain a session reference table.** Rejected because + it creates a third representation of worktree state and leaves a permanent + compatibility model. The materialization and branch-add paths can instead be + reordered to persist the existing task environment owner and compensate on + failure. +4. **Keep `task_session_worktrees` as a reduced reference table.** Rejected + because `task_sessions.task_environment_id` already identifies the complete + shared workspace. A second association is redundant and can drift. +5. **Rely on an in-process task mutex.** Rejected because it does not protect + PostgreSQL multi-connection races or backend restart boundaries. +6. **Ship an additive release and drop legacy schema later.** Rejected because it + creates a dual-write period, permits drift, and leaves users indefinitely on + different ownership models. +7. **Use best-effort migration statements.** Rejected because swallowed errors + can commit a partially normalized database. This cutover must return every + unexpected error and roll back as a unit. +8. **Keep downgrade-compatible legacy tables.** Rejected because those tables + recreate the dual ownership model. Recovery uses the pre-upgrade backup + instead of permanent compatibility storage. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 08dc74cf30..74ce617679 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -133,3 +133,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 | | 2026-08-08-agentctl-crash-containment | [Publish Immutable Agent Events and Explicit Runtime Failure State](2026-08-08-agentctl-crash-containment.md) | proposed | backend, frontend, protocol, operations | 2026-08-08 | | 2026-08-08-workspace-scoped-task-create-workflow-memory | [Remember Task-Create Workflows Per Workspace](2026-08-08-workspace-scoped-task-create-workflow-memory.md) | accepted | backend, frontend | 2026-08-08 | +| 2026-08-08-task-owned-worktree-lifetime | [Keep Worktree Ownership at the Task Lifecycle](2026-08-08-task-owned-worktree-lifetime.md) | accepted | backend, frontend | 2026-08-08 | diff --git a/docs/plans/task-owned-worktree-lifecycle/plan.md b/docs/plans/task-owned-worktree-lifecycle/plan.md new file mode 100644 index 0000000000..39afa05cd5 --- /dev/null +++ b/docs/plans/task-owned-worktree-lifecycle/plan.md @@ -0,0 +1,268 @@ +--- +spec: docs/specs/session-delete-resource-cleanup/spec.md +decision: docs/decisions/2026-08-08-task-owned-worktree-lifetime.md +created: 2026-08-08 +status: implemented +--- + +# Implementation Plan: Preserve Task-Owned Worktrees Across Session Deletion + +## Overview + +Redesign the closed PR #2421 around the actual ownership boundary: a task owns +its materialized worktrees, while sessions only reference them. Deleting a +session must remove its conversation and associations without invoking any +filesystem, Git worktree, or branch cleanup. Archive, delete, cascade, workspace +delete, quick-chat expiry, and explicit environment reset remain the only +physical cleanup owners and continue through the durable cleanup worker. + +The persistence repair finishes the existing task-environment model instead of +adding another table. `task_environment_repos` becomes the sole physical +worktree record, `task_sessions.task_environment_id` is the only session link, +and `task_session_worktrees` plus deprecated flat environment worktree columns +are removed after a validated SQLite/PostgreSQL backfill. A durable task-lifecycle +preparation barrier closes the race between inventory capture and new +session/worktree creation without holding database locks while Git or filesystem +locks are held. + +Implementation starts from `main`; do not carry forward the PR's +`session_delete` cleanup trigger/job, `resource_cleanup_session_delete.go`, or +`ReclaimSessionWorktree` physical cleanup path. + +## Persistence and Worktree Store + +Likely files: + +- `apps/backend/internal/task/repository/sqlite/base_schema.go` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go` +- `apps/backend/internal/task/repository/sqlite/schema_replay_test.go` +- `apps/backend/internal/worktree/store.go` +- `apps/backend/internal/worktree/manager.go` +- `apps/backend/internal/worktree/manager_lifecycle.go` +- `apps/backend/internal/backendapp/branch_materializer.go` +- `apps/backend/internal/task/models/models.go` +- `apps/web/lib/api/domains/task-environment-api.ts` +- task-environment API consumers that still read deprecated flat fields + +Extend `task_environment_repos` with the lifecycle fields needed by worktree +cleanup, then backfill it from existing environment-repository rows, deprecated +flat `task_environments` metadata, and `task_session_worktrees`. Sessions with a +valid environment retain it; otherwise normalize each connected legacy +worktree/session group under exactly one task-owned environment. Fail closed on +incompatible task, environment, repository, identity, path, or branch data. + +In the same upgrade, validate the final ownership graph, rebuild/drop +`task_session_worktrees`, and rebuild/drop the deprecated +`task_environments.repository_id`, `worktree_id`, `worktree_path`, and +`worktree_branch` columns. SQLite and PostgreSQL must roll back the entire +normalization if validation fails. Fresh schema contains only the final tables. +There is no runtime dual-read/dual-write window. + +Implement this as a dedicated migration that returns every error; do not use the +best-effort `MigrateLogger.Apply` path. Acquire the SQLite writer lock or a +PostgreSQL migration advisory lock, populate shadow tables, and compare the +complete old/new worktree identity, path, and branch sets before any schema swap. +Validate session-to-environment resolution, canonical owner uniqueness, row +counts, constraints, foreign keys, and the exact final column/index set inside +the transaction. Injected failures at every copy/validation/drop/rename boundary +must prove rollback restores the complete pre-upgrade schema and data. + +SQLite's existing pre-upgrade `VACUUM INTO` snapshot is mandatory and remains +fatal on failure. PostgreSQL relies on transactional DDL plus the repository's +existing operator-managed backup policy. Its transaction takes a migration +advisory lock plus exclusive locks on the affected ownership tables, and aborts +on lock timeout. Deployment documentation must require mixed-version PostgreSQL +writers to stop before cutover and explain that downgrade restores the backup; +the clean final schema is not readable by an older binary. The migration never +touches a workspace directory or invokes Git, and repository initialization must +complete before the cleanup worker starts. + +Defensively remove any preview-build cleanup jobs with trigger +`session_delete` before the worker starts, then remove that trigger identity and +all session-specific cleanup implementation. Preserve every task-lifecycle job. + +Persist the environment, repository worktrees, and session environment link in +the owning transaction after physical materialization. If persistence is +rejected, remove only the just-created physical worktree as compensation. Move +task/session inventory, storage inventory, branch updates, API projections, and +live-path queries to the normalized environment-repository graph. Remove the +`TaskSessionWorktree` model/CRUD surface and the worktree store's duplicate schema +initializer. + +## Task Lifecycle Barrier and Session Deletion + +Likely files: + +- `apps/backend/internal/task/service/resource_cleanup_jobs.go` +- `apps/backend/internal/task/repository/sqlite/resource_cleanup.go` +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/executor/task_environment.go` +- `apps/backend/internal/orchestrator/executor/executor_environment_reuse.go` + +Reserve a prepared `task_resource_cleanup_jobs` row before task resource +inventory is captured. Session creation and canonical environment-repository +persistence must serialize against the task row and reject creation while that +lifecycle barrier is active. PostgreSQL uses row locking; SQLite uses its +serialized writer transaction. Commit the barrier before acquiring target-path, +repository, or Git locks so the existing lock order is not inverted. + +Keep `DeleteSession` limited to runtime quiescence, session-row deletion, +in-memory state cleanup, and primary-session promotion. Add a regression seam or +spy that proves the method cannot enqueue resource cleanup or call a physical +worktree cleaner, including when it deletes the final session. + +## Durable Task Cleanup + +Likely files: + +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/internal/task/service/handoff_cascade.go` +- `apps/backend/internal/task/service/resource_cleanup_jobs.go` +- `apps/backend/internal/task/service/service_cleanup_inventory_test.go` +- `apps/backend/internal/task/service/resource_cleanup_jobs_test.go` +- `apps/backend/internal/worktree/manager_cleanup.go` +- `apps/backend/internal/worktree/store.go` +- `apps/backend/internal/backendapp/storage_inventory.go` +- `apps/backend/internal/automation/run_retention.go` +- `apps/backend/internal/office/infra/gc.go` + +Change `gatherWorktreesForDelete` and related inventory to read +`task_environment_repos` through `task_environments.task_id`, so cleanup remains +possible after every session row is gone and after a backend restart. Snapshot +those handles into the durable job before archive/delete/cascade mutation. +Preserve the existing shared-environment and active-session checks, including +ownership transfer when another task borrows the environment. A transfer changes +the environment owner once; its repository worktrees follow automatically. + +Continue to use `CleanupWorktrees`/`removeWorktree` only from task lifecycle +cleanup. Filesystem or Git failures remain retryable through the existing worker; +restart must reclaim pending jobs from their snapshots. + +## Frontend, Mobile, and Documentation + +Likely files: + +- `apps/web/components/task/session-tab-menu.tsx` +- `apps/web/components/task/session-tab-menu.test.tsx` +- `apps/web/components/task/mobile/mobile-sessions-section.tsx` +- `apps/web/components/task/mobile/mobile-sessions-section.test.tsx` +- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/task.json` +- `apps/web/e2e/tests/session/multi-session-ux.spec.ts` +- `apps/web/e2e/tests/session/mobile-session-deletion.spec.ts` +- `docs/public/sessions-and-review.md` +- `docs/public/operations.md` +- `docs/public/k8s.md` + +Keep the existing desktop AlertDialog and native mobile session-actions dialog. +Their destructive action deletes conversation history, so the confirmation copy +must explicitly state that the task workspace and files are retained. Do not add +uncommitted/unpushed-work fetches or warnings to either surface; those warnings +remain on task archive/delete cleanup dialogs. + +Desktop and mobile retain the same action availability and confirmation +semantics. This is a copy/contract change, not a compressed desktop layout: +mobile keeps its existing actions-sheet entry point, viewport-safe AlertDialog, +and touch targets. Rendered component tests cover both surfaces and the existing +mobile Playwright flow verifies the native entry point. + +Update public session documentation to distinguish ordinary session deletion +from task archive/delete. Do not change the separate Quick Chat documentation: +closing Quick Chat deletes its backing task and is therefore a task-lifecycle +cleanup operation. Update the operations and Kubernetes upgrade/rollback guides +with the clean-cutover backup, stopped-writer, single-initializer, and downgrade +restore requirements. + +## Implementation Waves + +Wave 1: + +- [x] [task-01-task-worktree-persistence](task-01-task-worktree-persistence.md) + +Wave 2: + +- [x] [task-02-lifecycle-creation-barrier](task-02-lifecycle-creation-barrier.md) + +Wave 3: + +- [x] [task-03-task-owned-durable-cleanup](task-03-task-owned-durable-cleanup.md) + +Wave 4: + +- [x] [task-04-session-delete-contract-and-e2e](task-04-session-delete-contract-and-e2e.md) + +## Verification + +Backend focused tests: + +```bash +cd apps/backend && go test ./internal/task/repository/... ./internal/worktree/... +cd apps/backend && go test ./internal/task/service/... ./internal/orchestrator/... +cd apps/backend && go test ./internal/backendapp/... ./internal/automation/... ./internal/office/infra/... +``` + +Migration safety tests must cover fresh, replay, legacy single-repository, +multi-repository, shared-environment, zero-session, preview-PR, ambiguous-data, +and injected-failure fixtures on both SQLite and PostgreSQL. PostgreSQL coverage +must run with a real isolated database; a skipped run is not release evidence. + +PostgreSQL migration tests must run with the repository's configured PostgreSQL +test DSN in addition to SQLite. A skipped PostgreSQL run is not final evidence. + +Frontend bootstrap and focused tests: + +```bash +cd apps && pnpm install --frozen-lockfile +cd apps && pnpm --filter @kandev/web test -- components/task/session-tab-menu.test.tsx components/task/mobile/mobile-sessions-section.test.tsx +cd apps/web && pnpm run typecheck +cd apps && pnpm run i18n:ratchet && pnpm run i18n:check +``` + +Managed desktop and mobile E2E: + +```bash +cd apps/web && pnpm e2e:run tests/session/multi-session-ux.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-deletion.spec.ts +``` + +Public documentation validation: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Risks + +- Backfill ambiguity must stop the migration with diagnostic detail; silently + choosing a path can leak or delete the wrong worktree later. +- The normalization must not use a helper that swallows unexpected migration + errors. Every failure must abort repository initialization and roll back. +- PostgreSQL multi-instance startup must serialize through a database advisory + and affected-table locks; process-local locking is insufficient. +- Canonical ownership must be persisted at every materialization path, including + initial creation, resume/recreate, multi-repository additions, and mid-session + branch materialization. +- The lifecycle barrier must cover session and worktree creation in PostgreSQL, + not merely an in-process mutex or SQLite's single writer. +- Cleanup must not delete a worktree transferred to or actively borrowed by + another task between preparation and execution. +- Physical compensation after a rejected environment-repository transaction must + target only the just-created worktree and preserve pre-existing + directories/registrations. +- Every legacy reader and writer must be removed in the normalization task; + storage inventory, automation retention, branch updates, API projections, and + orphan GC cannot ship with a fallback to the deleted table or columns. +- CI must assert that legacy table/type/trigger names occur only in the one-way + migration and its fixtures, never in production runtime packages. +- Because obsolete schema is removed, rollback means restoring the pre-upgrade + database backup; an old binary must never be started against the normalized + database. + +## Documentation Impact + +The session-delete preservation spec, runtime-cleanup spec, and ownership ADR +define the durable contract. Public session documentation and localized +confirmation copy must state that deleting a session retains the task workspace, +while existing archive/delete warnings continue to describe physical cleanup. diff --git a/docs/plans/task-owned-worktree-lifecycle/task-01-task-worktree-persistence.md b/docs/plans/task-owned-worktree-lifecycle/task-01-task-worktree-persistence.md new file mode 100644 index 0000000000..64f60000f3 --- /dev/null +++ b/docs/plans/task-owned-worktree-lifecycle/task-01-task-worktree-persistence.md @@ -0,0 +1,132 @@ +--- +id: "01-task-worktree-persistence" +title: "Normalize task-environment worktree ownership" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/session-delete-resource-cleanup/spec.md" +decision: "../../decisions/2026-08-08-task-owned-worktree-lifetime.md" +--- + +# Task 01: Normalize Task-Environment Worktree Ownership + +## Acceptance + +- `task_environment_repos` is the only physical-worktree record and is queryable + by task through its owning `task_environments` row without a live session. +- `task_sessions.task_environment_id` is the only session/workspace association. +- `task_session_worktrees`, its indexes, Go model, repository methods, duplicate + schema initializer, and runtime readers/writers are removed. +- Deprecated flat `repository_id`, `worktree_id`, `worktree_path`, and + `worktree_branch` fields are removed from the `task_environments` schema, + models, persistence methods, and API. +- Initial, resumed/recreated, multi-repository, and mid-session materialization + paths persist environment repository ownership before exposing the worktree + for reuse. +- Environment ownership, repository worktrees, and the session environment link + are committed consistently. +- Rejected persistence compensates only a worktree created by that attempt. +- SQLite and PostgreSQL upgrades normalize legacy environment and session data, + validate it, and remove obsolete schema in one rollback-safe migration. +- The cutover uses a dedicated error-returning migration under a database writer + or advisory lock; it never uses a helper that swallows unexpected errors. +- The complete legacy and final worktree identity/path/branch inventories match + before the schema swap commits. +- Fresh databases contain only the final schema; production runtime code has no + legacy read, write, or fallback path. +- Preview-build `session_delete` cleanup jobs are removed before worker startup; + task-lifecycle cleanup jobs are preserved. +- Conflicting legacy identities or paths fail migration with enough detail to + repair the data, leaving the pre-upgrade database intact. +- Injected failure at every copy, validation, drop, rename, and commit boundary + rolls back to the complete old schema and data. +- PostgreSQL advisory and affected-table locks reject concurrent migration or + mixed-version writes during cutover; lock timeout changes nothing. +- The migration performs no filesystem or Git operations, and cleanup workers + cannot start against a partially initialized repository. +- SQLite snapshot restore and PostgreSQL operator-backup restore are verified as + the downgrade path; old binaries are never run against the final schema. +- Task inventory and active-worktree-path reads no longer require a session join. + +## TDD Sequence + +1. RED: add SQLite and PostgreSQL fresh-schema tests for the exact final + environment/repository/session shape and absence of obsolete tables/columns. +2. RED: seed a task environment, a legacy flat environment, and a session-only + association; prove all three normalize into environment repository ownership, + sessions receive the correct environment ID, and session deletion preserves + the owner. Add conflicting-source cases that roll back the migration. +3. RED: assert successful upgrade drops `task_session_worktrees` and deprecated + environment columns for both database engines, including migration replay. +4. RED: inject errors after each shadow-copy, validation, drop, rename, and + pre-commit step; reopen the database and prove its legacy schema and data are + byte-for-byte equivalent at the ownership-field level. +5. RED: exercise concurrent PostgreSQL initializers and prove the advisory lock + and affected-table locks serialize the cutover and reject mixed-version + writes. Exercise SQLite writer contention and prove timeout or failure leaves + the database unchanged. +6. RED: add store tests for task and session projections through the environment, + zero-session inventory, ownership transfer, and atomic persistence. +7. GREEN: implement the schema normalization and remove legacy models, CRUD, + API fields, schema initialization, and dual-write paths. +8. GREEN: wire every physical materialization path and bounded compensation. +9. REFACTOR: make `task_environment_repos` the shared projection for branch + updates, session responses, storage inventory, and worktree management. +10. REFACTOR: add an architecture assertion that legacy ownership symbols are + confined to the migration and its fixtures. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/repository/sqlite/... ./internal/worktree/... +cd apps/backend && go test ./internal/backendapp/... -run 'Worktree|BranchMaterial' +``` + +Run PostgreSQL schema/migration tests with the repository's configured test DSN; +recording only a skipped result is incomplete. + +Also run the persistence provider upgrade tests to prove SQLite backup failure +prevents migration and successful upgrade leaves a restorable snapshot. + +## Files likely touched + +- `apps/backend/internal/task/repository/sqlite/base_schema.go` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go` +- `apps/backend/internal/task/repository/sqlite/schema_replay_test.go` +- `apps/backend/internal/persistence/upgrade_test.go` +- `apps/backend/internal/persistence/snapshot_test.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/worktree/store.go` +- `apps/backend/internal/worktree/manager.go` +- `apps/backend/internal/worktree/manager_lifecycle.go` +- `apps/backend/internal/backendapp/branch_materializer.go` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/task_environment.go` +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/orchestrator/executor/executor.go` +- `apps/web/lib/api/domains/task-environment-api.ts` +- task-environment API consumers that use deprecated flat worktree fields + +## Dependencies + +None. + +## Inputs + +- `docs/specs/tasks/runtime-cleanup.md`, especially the normalized task + environment data model. +- `docs/decisions/2026-08-08-task-owned-worktree-lifetime.md`. +- Existing `TaskEnvironment` ownership-transfer behavior and worktree manager + materialization rollback patterns. + +## Output contract + +Report the exact final schema, removed tables/columns/types/methods, backfill +grouping and source precedence, old/new inventory comparison, conflict +diagnostics, every injected rollback result, SQLite snapshot evidence, +PostgreSQL advisory-lock evidence, every materialization path audited, +compensation behavior, backup-restore/downgrade evidence, and SQLite/PostgreSQL +results. No legacy reader or writer may be deferred. diff --git a/docs/plans/task-owned-worktree-lifecycle/task-02-lifecycle-creation-barrier.md b/docs/plans/task-owned-worktree-lifecycle/task-02-lifecycle-creation-barrier.md new file mode 100644 index 0000000000..c093c662d5 --- /dev/null +++ b/docs/plans/task-owned-worktree-lifecycle/task-02-lifecycle-creation-barrier.md @@ -0,0 +1,81 @@ +--- +id: "02-lifecycle-creation-barrier" +title: "Serialize task cleanup with creation" +status: done +wave: 2 +depends_on: ["01-task-worktree-persistence"] +plan: "plan.md" +spec: "../../specs/session-delete-resource-cleanup/spec.md" +decision: "../../decisions/2026-08-08-task-owned-worktree-lifetime.md" +--- + +# Task 02: Serialize Task Cleanup With Session and Worktree Creation + +## Acceptance + +- Archive/delete preparation reserves a durable lifecycle barrier before + capturing resource inventory. +- Session creation and canonical environment-repository persistence serialize + against the owning task and cannot commit after cleanup preparation has begun. +- PostgreSQL row locking and SQLite writer serialization enforce the same + contract without relying on an in-process mutex. +- The barrier transaction commits before filesystem, target-path, repository, or + Git locks are acquired. +- A worktree physically created before a concurrent barrier rejection is safely + compensated without touching pre-existing resources. +- `DeleteSession` does not reserve or activate a cleanup job and cannot invoke a + physical worktree cleaner, including for the last session. +- A new session created after an old session is deleted reuses the retained task + environment when no task-lifecycle barrier exists. + +## TDD Sequence + +1. RED: add repository concurrency tests that pause archive/delete after barrier + reservation and prove session/environment-repository insertion cannot cross + the barrier. +2. RED: exercise the inverse ordering and prove a committed session/worktree is + included in the later task snapshot. +3. RED: add a materialization test where the database barrier rejects the owner + transaction after Git creation and assert bounded, target-specific rollback. +4. RED: add `DeleteSession` regressions for the only session and one of two shared + sessions, with a spy that fails on cleanup-job activation or physical cleanup. +5. GREEN: implement the prepared-job barrier and task-row serialization for both + database engines. +6. REFACTOR: make lock ordering explicit in comments/tests and keep Git locks + outside the database transaction. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/repository/sqlite/... -run 'Cleanup|Barrier|Session' +cd apps/backend && go test ./internal/task/service/... -run 'Cleanup|Archive|Delete' +cd apps/backend && go test ./internal/orchestrator/... -run 'DeleteSession|Environment|Worktree' +``` + +## Files likely touched + +- `apps/backend/internal/task/service/resource_cleanup_jobs.go` +- `apps/backend/internal/task/repository/sqlite/resource_cleanup.go` +- `apps/backend/internal/task/repository/sqlite/session.go` +- `apps/backend/internal/worktree/store.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/executor/task_environment.go` +- `apps/backend/internal/orchestrator/executor/executor_environment_reuse.go` + +## Dependencies + +- Task 01 supplies the canonical environment-repository transaction and + compensation seam. + +## Inputs + +- Existing task operation locks and worktree manager target-path/repository lock + order. +- Existing prepared/activated `task_resource_cleanup_jobs` lifecycle. + +## Output contract + +Report the exact serialization point for SQLite and PostgreSQL, both tested race +orderings, the lock-order proof, materialization compensation result, and a call +trace showing that session deletion performs no filesystem, Git, branch, or +cleanup-job action. diff --git a/docs/plans/task-owned-worktree-lifecycle/task-03-task-owned-durable-cleanup.md b/docs/plans/task-owned-worktree-lifecycle/task-03-task-owned-durable-cleanup.md new file mode 100644 index 0000000000..b719345a8a --- /dev/null +++ b/docs/plans/task-owned-worktree-lifecycle/task-03-task-owned-durable-cleanup.md @@ -0,0 +1,87 @@ +--- +id: "03-task-owned-durable-cleanup" +title: "Clean task-owned worktrees durably" +status: done +wave: 3 +depends_on: ["02-lifecycle-creation-barrier"] +plan: "plan.md" +spec: "../../specs/session-delete-resource-cleanup/spec.md" +decision: "../../decisions/2026-08-08-task-owned-worktree-lifetime.md" +--- + +# Task 03: Clean Task-Owned Worktrees Durably + +## Acceptance + +- Archive, delete, cascade archive/delete, workspace delete, quick-chat expiry, + and explicit environment reset inventory `task_environment_repos` through the + owning environment's task ID. +- Task cleanup still finds a worktree after its last session was deleted and the + backend restarted. +- The durable cleanup snapshot survives deletion of task and session rows. +- Archive marks the task archived and schedules asynchronous cleanup; delete + removes the task and schedules asynchronous cleanup. +- Filesystem and Git worktree failures enter the existing bounded retry schedule + and resume after restart. +- Cleanup preserves resources borrowed or actively referenced by other tasks or + sessions. +- TaskEnvironment ownership transfer changes its owner once; canonical + repository worktrees follow without a second ownership write. +- `CleanupWorktrees`/`removeWorktree` have task-lifecycle callers only; no + `ReclaimSessionWorktree` or `session_delete` trigger/job exists. +- Storage inventory, automation retention, and orphan GC use canonical live + owner records rather than session-derived rows. + +## TDD Sequence + +1. RED: delete the only session, recreate the service/repository, then + archive/delete the task and prove inventory still contains the worktree. +2. RED: add archive and delete integration tests that observe asynchronous + cleanup, Git registration removal, branch policy, and task-row outcomes. +3. RED: inject a transient filesystem/Git failure, recreate the worker, advance + the retry time, and prove the durable snapshot succeeds on a later claim. +4. RED: cover a worktree borrowed by another active task/session and an ownership + transfer; prove cleanup skips or transfers it rather than deleting it. +5. GREEN: change task inventory and cleanup to canonical environment repository + rows and snapshot those handles before lifecycle mutation. +6. GREEN: migrate storage inventory, automation retention, and orphan-GC reads. +7. REFACTOR: remove session-derived physical cleanup APIs and assert the allowed + caller boundary in focused tests. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/service/... -run 'Archive|Delete|Cleanup|Cascade|Worktree' +cd apps/backend && go test ./internal/worktree/... -run 'Cleanup|Shared|Task' +cd apps/backend && go test ./internal/backendapp/... ./internal/automation/... ./internal/office/infra/... +``` + +## Files likely touched + +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/internal/task/service/handoff_cascade.go` +- `apps/backend/internal/task/service/resource_cleanup_jobs.go` +- `apps/backend/internal/task/service/service_cleanup_inventory_test.go` +- `apps/backend/internal/task/service/resource_cleanup_jobs_test.go` +- `apps/backend/internal/worktree/manager_cleanup.go` +- `apps/backend/internal/worktree/store.go` +- `apps/backend/internal/backendapp/storage_inventory.go` +- `apps/backend/internal/automation/run_retention.go` +- `apps/backend/internal/office/infra/gc.go` + +## Dependencies + +- Task 02 establishes the lifecycle creation barrier around inventory capture. + +## Inputs + +- Existing durable cleanup worker, retry schedule, archive revalidation, and + shared-environment session checks. +- Existing task/cascade/workspace/quick-chat lifecycle entry points. + +## Output contract + +Report every task-lifecycle caller audited, the canonical inventory query, the +restart/retry evidence, shared-resource outcomes, task-row outcomes for archive +and delete, Git registration/branch results, and proof that no session-delete +physical cleanup entry point remains. diff --git a/docs/plans/task-owned-worktree-lifecycle/task-04-session-delete-contract-and-e2e.md b/docs/plans/task-owned-worktree-lifecycle/task-04-session-delete-contract-and-e2e.md new file mode 100644 index 0000000000..c455419dd5 --- /dev/null +++ b/docs/plans/task-owned-worktree-lifecycle/task-04-session-delete-contract-and-e2e.md @@ -0,0 +1,101 @@ +--- +id: "04-session-delete-contract-and-e2e" +title: "Verify session deletion preserves workspaces" +status: done +wave: 4 +depends_on: ["03-task-owned-durable-cleanup"] +plan: "plan.md" +spec: "../../specs/session-delete-resource-cleanup/spec.md" +decision: "../../decisions/2026-08-08-task-owned-worktree-lifetime.md" +--- + +# Task 04: Verify Session Deletion Preserves Workspaces + +## Acceptance + +- Desktop and mobile confirmation dialogs say that conversation history is + deleted while the task workspace and files are retained. +- Neither dialog fetches or warns about uncommitted/unpushed state; destructive + workspace warnings remain on task archive/delete. +- Deleting the only session leaves the task with zero sessions, its directory, + Git worktree registration, and uncommitted file intact; a new session reuses + the retained workspace. +- Deleting one of two sessions sharing a workspace leaves the other session + operational in that workspace. +- The existing mobile session-actions sheet and viewport-safe confirmation dialog + remain the native entry point with unchanged action semantics and touch targets. +- Public docs distinguish ordinary session deletion from task archive/delete and + keep Quick Chat's task-backed cleanup semantics separate. +- Operations and Kubernetes upgrade docs require a verified PostgreSQL backup, + stopped mixed-version backends, one successful schema initializer, and backup + restore before binary downgrade; they explain SQLite's automatic pre-upgrade + snapshot. +- All new copy is localized in English, pseudo, Portuguese, and Simplified + Chinese catalogs. + +## TDD Sequence + +1. RED: extend desktop and mobile rendered-component tests for the retained- + workspace copy and absence of uncommitted/unpushed warnings. +2. RED: add a managed desktop E2E that writes an uncommitted marker, deletes the + only session through visible UI, verifies zero sessions plus directory/Git + registration/marker preservation, and creates a replacement session that + observes the marker. +3. RED: add the two-session shared-workspace case and verify the remaining + session continues to operate. +4. RED: extend the mobile E2E to assert the same confirmation contract from the + native session-actions sheet. +5. GREEN: update localized dialog copy without adding new warning-fetch state. +6. GREEN: update public session documentation. +7. GREEN: update the existing operations/Kubernetes upgrade and rollback guides + with the one-time ownership-schema cutover requirements. +8. REFACTOR: share only translation keys or test helpers that reduce duplication; + retain the existing desktop and mobile compositions. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile +cd apps && pnpm --filter @kandev/web test -- components/task/session-tab-menu.test.tsx components/task/mobile/mobile-sessions-section.test.tsx +cd apps/web && pnpm run typecheck +cd apps && pnpm run i18n:ratchet && pnpm run i18n:check +cd apps/web && pnpm e2e:run tests/session/multi-session-ux.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-deletion.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/components/task/session-tab-menu.tsx` +- `apps/web/components/task/session-tab-menu.test.tsx` +- `apps/web/components/task/mobile/mobile-sessions-section.tsx` +- `apps/web/components/task/mobile/mobile-sessions-section.test.tsx` +- `apps/web/src/locales/en/task.json` +- `apps/web/src/locales/pseudo/task.json` +- `apps/web/src/locales/pt-pt/task.json` +- `apps/web/src/locales/zh-cn/task.json` +- `apps/web/e2e/tests/session/multi-session-ux.spec.ts` +- `apps/web/e2e/tests/session/mobile-session-deletion.spec.ts` +- `docs/public/sessions-and-review.md` +- `docs/public/operations.md` +- `docs/public/k8s.md` + +## Dependencies + +- Task 03 completes the backend ownership and durable task-cleanup contract. + +## Inputs + +- Current desktop `DeleteSessionDialog` and mobile + `DeleteSessionConfirmDialog` compositions. +- Existing task archive/delete cleanup summaries and warnings. +- Existing session Playwright fixtures and Git helpers. + +## Output contract + +Report desktop/mobile rendered test results, the exact user-visible contract, +zero-session and shared-session E2E evidence, Git registration and uncommitted +marker results, replacement-session reuse evidence, the documented +SQLite/PostgreSQL upgrade and downgrade procedure, i18n/typecheck/docs results, +and screenshots or traces for any failed mobile state. diff --git a/docs/public/k8s.md b/docs/public/k8s.md index ed89541a14..589b0ca2cd 100644 --- a/docs/public/k8s.md +++ b/docs/public/k8s.md @@ -244,9 +244,15 @@ kubectl rollout status deployment/kandev kubectl logs deployment/kandev --tail=200 ``` -The `Recreate` strategy stops active local agents. SQLite migrations run on startup and create a pre-migration snapshot when required. PostgreSQL migrations do not invoke `pg_dump`. +The `Recreate` strategy stops active local agents. SQLite migrations run on startup and create a pre-migration snapshot when required; snapshot failure aborts startup. PostgreSQL migrations do not invoke `pg_dump`, so take and verify a PostgreSQL backup before the upgrade. -`kubectl rollout undo` changes the image, not the database schema. A binary downgrade may not understand a newer schema; restore the matching pre-upgrade database backup when required. Reapplying the checked-in `k8s/deployment.yaml` without the image transformation resets the image to `kandev:latest`, so keep your production customization in your own overlay or deployment repository. +This release includes a one-time task-worktree ownership schema cutover (see [Operations](operations.md)). The cutover rewrites the worktree ownership tables in one transaction and drops legacy schema. It requires: + +- A verified pre-upgrade database backup. For SQLite, create and verify a manual snapshot **before** starting the upgrade — the automatic pre-migration snapshot is taken during startup of the new binary and cannot be verified beforehand. For PostgreSQL, use `pg_dump` (the cutover does not invoke it). +- All writers stopped during the cutover — with PostgreSQL, do not run a mixed-version fleet across the upgrade; the cutover takes a database advisory lock and fails closed if it cannot serialize. +- One successful schema initializer: keep the deployment at a single replica during startup, and check `kubectl rollout status` before scaling out. + +`kubectl rollout undo` changes the image, not the database schema. After the cutover the final schema is intentionally not readable by older binaries, so a binary downgrade requires restoring the matching pre-upgrade database backup — do not start an older image against the normalized database. Reapplying the checked-in `k8s/deployment.yaml` without the image transformation resets the image to `kandev:latest`, so keep your production customization in your own overlay or deployment repository. ## Remove while retaining data diff --git a/docs/public/operations.md b/docs/public/operations.md index f36f60d809..9b24b6abf4 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -228,6 +228,14 @@ pg_dump --host "$PGHOST" --port "${PGPORT:-5432}" \ "${PGDATABASE:-kandev}" ``` +Some releases perform a one-time schema cutover that rewrites ownership tables and drops legacy schema (for example the task-worktree ownership normalization). Before such an upgrade: + +1. Take and **verify** a backup. For PostgreSQL use the pattern above. For SQLite, create a manual snapshot from **Settings > System > Backups** (or `sqlite3` `VACUUM INTO`) and verify it: the automatic pre-migration snapshot is taken during startup of the new binary, so it cannot be verified before the upgrade starts. +2. Stop all backend writers during the cutover. Do not run a mixed-version fleet across the upgrade; the migration takes a database advisory lock and aborts on lock timeout without changing the schema or data. +3. Let exactly one schema initializer run and reach a healthy state before starting additional instances. + +The normalized schema is intentionally incompatible with older binaries. To downgrade, stop all instances and restore the pre-upgrade backup — never start an older binary against a post-cutover database. SQLite restores from its automatic pre-migration snapshot; PostgreSQL restores your verified `pg_dump` backup. + Switching `database.driver` does not migrate data. PostgreSQL and shared NATS remove two single-process data constraints, but they do not make Kandev horizontally scalable: WebSocket subscriptions, execution lifecycle/control state, and task workspaces remain process- or filesystem-local. The current product and supplied deployment validate one backend replica only; do not add replicas based on the database and event bus alone. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index 9d89db1294..894b20720d 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -54,7 +54,7 @@ Right-click an agent tab on desktop to manage it. Available actions depend on it | **Set as Primary** | Makes a stoppable session the task's primary target | | **Stop** | Cancels the active agent turn for this session | | **Resume** | Attempts to continue a completed, failed, or cancelled session | -| **Delete** | Permanently removes the conversation; if it was primary, another session is promoted when possible | +| **Delete** | Permanently removes the conversation; if it was primary, another session is promoted when possible. The task workspace and its files are kept — a later session reuses them | | **Share** | Opens the publishing preview for an eligible session | | **Handoff** | Starts another session with a generated summary of this conversation | | **Close Others** | Closes other visible agent panels without deleting their sessions | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index cbab08a985..691de39b46 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -77,6 +77,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [subtree-controls](tasks/subtree-controls.md) | shipped | | [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft | | [runtime-cleanup](tasks/runtime-cleanup.md) | draft | +| [session-delete-resource-cleanup](session-delete-resource-cleanup/spec.md) | draft | | [archive-confirmation](tasks/archive-confirmation.md) | shipped | | [link-existing-task-github-issue](tasks/link-existing-task-github-issue.md) | building | | [wip-limit-pull-system](tasks/wip-limit-pull-system.md) | draft | diff --git a/docs/specs/office/agents.md b/docs/specs/office/agents.md index fdd3aa7ec4..e90bd1204f 100644 --- a/docs/specs/office/agents.md +++ b/docs/specs/office/agents.md @@ -575,7 +575,7 @@ What does NOT survive a restart: - **In-flight agent sessions**: the agent subprocess and its agentctl HTTP server are owned by the kandev process; both die when the backend exits. The `TaskSession.ACPSessionID` and `office_runs` row are retained, so the orchestrator's `RecoverInstances` path can resume the session — but the partially-streamed turn at the moment of shutdown is lost unless the underlying CLI itself supports replay. - **Queued wakeups for capacity-saturated agents**: wakeups that were already persisted survive (they live in the same DB tables as the rest). Wakeups that were only held in memory at the time of shutdown are lost; the originating event (task assign, comment, routine fire) must re-emit to re-queue. - **Per-run JWTs** (`KANDEV_API_KEY`) and the rest of the agent's environment block are minted fresh per session. Old JWTs are not honoured after restart. -- **Worktrees on disk** belong to `task_session_worktrees`. They survive a backend restart but are reaped by the worktree GC if their owning `TaskSession` is gone. Injected `kandev-*` skill directories under a surviving worktree are stale until the next session rewrites them (the clean-slate step at session start handles this). +- **Worktrees on disk** belong to the task environment's repository rows. They survive backend restart and deletion of every session; physical cleanup begins only from task/environment lifecycle operations. The worktree GC treats the task-environment inventory as authoritative. Injected `kandev-*` skill directories under a surviving worktree are stale until the next session rewrites them (the clean-slate step at session start handles this). There are no TTLs on agent rows, runtime rows, instructions, skills, run history, or activity logs. Retention is by user action only. diff --git a/docs/specs/office/overview.md b/docs/specs/office/overview.md index 6768825e6f..a35480cc34 100644 --- a/docs/specs/office/overview.md +++ b/docs/specs/office/overview.md @@ -73,7 +73,7 @@ This spec is the top-level entry point for Office. It covers the workspace model - A project is a container for related tasks, scoped to one or more repositories or filesystem folders. - Tasks can be unprojectized (`project_id` null) and continue to behave as today on the kanban. - The CEO and worker agents can assign tasks to projects on creation; users can move tasks between projects or remove them via the UI. -- **Project repositories** are either a git URL (GitHub, GitLab, Bitbucket, any remote) or a local filesystem path. Tasks can target one repo (agent gets a single worktree) or several (multi-worktree session via the existing `TaskSessionWorktree[]` support). Repos are configured at the project level and inherited by tasks; tasks may target a subset. +- **Project repositories** are either a git URL (GitHub, GitLab, Bitbucket, any remote) or a local filesystem path. Tasks can target one repo (agent gets a single worktree) or several (a task environment owns one repository row per worktree). Repos are configured at the project level and inherited by tasks; tasks may target a subset. - **Project executor configuration** is an optional override that defines how agent sessions run for tasks in this project (executor type, image, resource limits, worktree strategy, network policy, environment, prepare scripts). If unset, the workspace default executor is used. - **Views**: - `/office/projects` lists projects with name, status, color, repo count and names, task counts (total/in-progress/completed/blocked), budget utilization, lead agent and status, and a progress bar. diff --git a/docs/specs/session-delete-resource-cleanup/spec.md b/docs/specs/session-delete-resource-cleanup/spec.md new file mode 100644 index 0000000000..b6f0c82ca4 --- /dev/null +++ b/docs/specs/session-delete-resource-cleanup/spec.md @@ -0,0 +1,266 @@ +--- +status: draft +created: 2026-08-08 +owner: cfl +--- + +# Session Delete Preserves Task Workspaces + +Decision: [Task-owned worktree lifetime](../../decisions/2026-08-08-task-owned-worktree-lifetime.md) + +Runtime contract: [Task runtime cleanup](../tasks/runtime-cleanup.md) + +## Why + +A session is a conversation and execution reference inside a task; it is not the +owner of the task's materialized workspace. A task may have multiple sessions +sharing one `TaskEnvironment`, may intentionally have zero sessions, and may +create a future session that reuses its existing files and Git state. + +PR #2421 proposed reclaiming a worktree when the final active session reference +disappeared. That rule confuses reference count with ownership. Deleting the last +session would destroy uncommitted work and remove a workspace that the still-live +task owns. The persistence model must instead keep task ownership durable until a +task lifecycle operation takes responsibility for cleanup. + +## What + +- Deleting a session removes its session row and conversation history. Its + `task_environment_id` reference disappears with the row; the task-owned + environment is unchanged. +- Deleting a session never removes a physical directory, runs `git worktree + remove` or `git worktree prune`, or deletes a branch. +- The rule applies when the deleted session is the task's only session and when + it is one of several sessions sharing the workspace. +- A task with zero sessions retains its `TaskEnvironment`, worktree directory, + Git registration, branch, and uncommitted files. +- A later session may reuse the retained workspace. +- Task archive/delete, cascade archive/delete, workspace delete, quick-chat task + expiry, and explicit task-environment reset remain the owners of physical + cleanup. +- Task lifecycle cleanup discovers task-owned resources without joining through a + session row and executes asynchronously through the durable cleanup worker. +- Resources borrowed by another task or referenced through a shared environment + are preserved or transferred according to existing ownership rules. +- Session deletion does not create or activate a `session_delete` cleanup job and + has no `ReclaimSessionWorktree` physical cleanup path. +- Session-delete confirmations describe the conversation deletion and explicitly + say that the task workspace and files remain. They do not warn that + uncommitted/unpushed workspace state will be removed. + +## Data Model + +### `task_environments` + +`task_environments` is the task-owned workspace root: + +```text +id string environment identity +task_id string owning task and inventory key +executor fields ... environment runtime configuration +workspace_path string agent-visible workspace root +container/sandbox ... non-worktree resource handles +status/timestamps ... environment lifecycle +``` + +The deprecated flat `repository_id`, `worktree_id`, `worktree_path`, and +`worktree_branch` columns are removed. Repository and physical worktree state +lives only in the environment's repository rows. + +### `task_environment_repos` + +`task_environment_repos` is the canonical physical-worktree table and also +represents repository preparation failures where no worktree was created: + +```text +id string environment-repository identity +task_environment_id string owning environment +repository_id string source repository +branch_slug string task repository/branch slot +worktree_id string|null physical worktree identity +worktree_path string|null absolute materialized path +worktree_branch string|null registered Git branch +position integer workspace ordering +error_message string preparation failure, when any +status enum active | merged | deleted +created_at timestamp +updated_at timestamp +merged_at/deleted_at timestamp|null +``` + +Task-level inventory queries these rows through +`task_environments.task_id`. Session projections query them through +`task_sessions.task_environment_id`. Neither query depends on a +session-to-worktree association. + +The canonical environment and repository rows are persisted after physical +materialization and before the workspace is exposed for reuse. A worktree that +was physically created but cannot be persisted because task cleanup won a race +is compensated before it becomes task state. + +### Schema normalization + +SQLite and PostgreSQL migrations backfill canonical rows from +`task_environment_repos`, legacy flat `task_environments` worktree fields, and +`task_session_worktrees`. Sessions with a valid `task_environment_id` retain it. +Legacy sessions are assigned to the matching existing environment or to a +normalized task-owned environment created for their connected worktree group. +Incompatible owners, paths, repository identities, or session groupings fail +closed with a diagnostic. + +After backfill validation, the same upgrade drops `task_session_worktrees` and +the deprecated flat worktree columns from `task_environments`. It also removes +the matching Go models, repository methods, duplicate schema initializer, API +fields, and dual-read/dual-write code. Fresh databases contain only the final +schema. There is no indefinite compatibility phase. + +If a database was opened by a preview build of PR #2421, the migration removes +its `session_delete` cleanup jobs before the cleanup worker can claim them. The +trigger constant and session-specific cleanup implementation are removed from +the codebase. Task-lifecycle cleanup jobs remain unchanged. + +The migration runs under an exclusive writer/migration lock in one transaction. +It builds normalized shadow tables, compares the full worktree identity, path, +and branch inventory before and after, validates all ownership and foreign-key +constraints, and swaps schemas only after every check passes. It returns errors +directly; it does not use the best-effort migration logger. + +SQLite takes the repository's existing fatal pre-upgrade snapshot before the +transaction. PostgreSQL uses transactional DDL under an advisory migration lock +and exclusive locks on the affected tables; lock timeout aborts the upgrade. +Mixed-version PostgreSQL writers must be stopped during cutover. The migration +performs no filesystem or Git operation, and no resource cleanup worker starts +until schema initialization commits successfully. + +### `task_resource_cleanup_jobs` + +The existing durable job is reserved in `prepared` state before task lifecycle +inventory is captured. Its snapshot contains canonical worktree handles before +archive/delete cascades can remove database rows. The job remains independent of +the task/session foreign-key lifetime and retains the existing retry schedule. + +There is no session-delete trigger value. + +## API Surface + +The WebSocket action is unchanged: + +```text +session.delete request { "session_id": string } +session.delete response { "success": true } +``` + +Success means that the session was deleted. It does not mean that task resources +were reclaimed. Existing validation, permission, running- +session refusal, quiescence, and primary-session promotion behavior remains. + +No new HTTP or WebSocket action is required for task cleanup. + +## State and Concurrency + +Session deletion follows: + +```text +stopped session -> quiesced runtime -> session/reference deletion -> task retained +``` + +Task lifecycle cleanup follows: + +```text +active task -> prepared cleanup barrier -> complete inventory snapshot + -> archive/delete mutation -> pending durable worker + -> running -> succeeded | retry_wait | failed +``` + +Session creation and canonical environment-repository persistence serialize with +the owning task row and check for an active prepared task cleanup barrier. +PostgreSQL uses row locking and SQLite uses the serialized writer transaction. +Either creation commits before the barrier and is included in inventory, or the +barrier commits first and creation is rejected/compensated. + +The database barrier transaction completes before any repository, target-path, +filesystem, or Git lock is acquired. Existing shared-worktree reference checks +run again during destructive cleanup so an ownership transfer or active borrower +cannot be destroyed by a stale decision. + +## Permissions + +Session deletion keeps its existing task/session authorization requirements. +This feature does not grant a session deleter permission to archive/delete the +task or remove task resources. + +Task archive/delete and cascade operations keep their existing permissions and +remain the authorization boundary for physical cleanup. + +## Failure Modes + +- If session deletion fails before commit, no filesystem or Git cleanup was + attempted and canonical task ownership is unchanged. +- If session deletion commits and the backend crashes, the canonical owner row + remains queryable by `task_id`; no recovery job is needed for session deletion. +- If migration cannot determine a single compatible owner/path, startup fails + closed, the transaction rolls back, and the pre-upgrade database remains + authoritative instead of authorizing future deletion from ambiguous data. +- If migration fails after shadow tables are populated or after legacy DDL has + begun, the database transaction restores the complete legacy schema and data. +- If SQLite cannot create its pre-upgrade snapshot, startup stops before the + migration transaction begins. +- If PostgreSQL cannot acquire its migration/table locks, startup stops without + changing the schema or data. +- An older binary cannot open the normalized schema. Downgrade restores the + SQLite automatic snapshot or the PostgreSQL operator backup. +- If a new session/worktree races task cleanup, the prepared barrier decides the + ordering; cleanup cannot snapshot a partial inventory. +- If physical compensation is required, it removes only the resource created by + the rejected materialization attempt. +- If archive/delete filesystem or Git cleanup fails, the durable task cleanup job + retries after failure and backend restart using its persisted snapshot. +- If another task/session still borrows the environment, cleanup preserves or + transfers the resource rather than deleting it. + +## Persistence Guarantees + +- Task worktree ownership in `task_environment_repos` survives deletion of any + or all session rows. +- Zero-session workspaces remain protected from storage inventory and orphan GC. +- Backend restart does not change the ownership boundary. +- A failed upgrade leaves no partially normalized schema. SQLite also retains a + directly restorable pre-upgrade snapshot. +- Archive/delete cleanup can query canonical resources by `task_id` before + mutation and can continue from the durable snapshot after task-row deletion. +- Historical branch metadata required for unarchive recovery remains preserved + according to the task runtime cleanup contract. +- No session-delete path invokes filesystem removal, Git worktree removal/prune, + or branch deletion. + +## Scenarios + +- **GIVEN** a task with one stopped session, a registered worktree, and an + uncommitted marker, **WHEN** the session is deleted, **THEN** the task has zero + sessions and the directory, registration, branch, and marker are unchanged. +- **GIVEN** that zero-session task, **WHEN** a replacement session starts, + **THEN** it reuses the task workspace and observes the marker. +- **GIVEN** two sessions sharing a task workspace, **WHEN** one is deleted, + **THEN** the other continues using the same directory without interruption. +- **GIVEN** the final session was deleted and the backend restarted, **WHEN** the + task is archived, **THEN** the task becomes archived and its canonical + worktree is scheduled for asynchronous cleanup unless shared. +- **GIVEN** the final session was deleted and the backend restarted, **WHEN** the + task is deleted, **THEN** the task row is removed and its worktree/Git + registration are removed by a retryable durable cleanup job. +- **GIVEN** a transient Git/filesystem failure during task cleanup, **WHEN** the + backend restarts and the retry becomes due, **THEN** cleanup resumes from the + snapshot and completes. +- **GIVEN** session/worktree creation and task deletion begin concurrently, + **WHEN** one reserves the task row first, **THEN** the resource is either fully + included in cleanup or rejected and compensated; it cannot become untracked. + +## Out of Scope + +- Reclaiming workspaces merely because their session reference count reaches + zero. +- A session-delete cleanup job, retry state, or physical reclaimer. +- Per-session uncommitted/unpushed warning fetches. +- New archive/delete UI controls or cleanup-failure UI. +- Changing Quick Chat closure, which deletes its hidden backing task and therefore + remains a task-lifecycle cleanup. diff --git a/docs/specs/system-page/storage-maintenance.md b/docs/specs/system-page/storage-maintenance.md index da4f82f588..337de93c5f 100644 --- a/docs/specs/system-page/storage-maintenance.md +++ b/docs/specs/system-page/storage-maintenance.md @@ -155,7 +155,7 @@ Retention override: ### Unarchive compatibility -- Storage cleanup preserves historical `task_session_worktrees` rows and branch metadata for +- Storage cleanup preserves historical task-environment repository rows and branch metadata for archived tasks. A historical row is recovery metadata, not proof that its old on-disk path is active. - When an archived task is unarchived while its workspace is quarantined and the quarantine entry diff --git a/docs/specs/tasks/agent-generated-titles.md b/docs/specs/tasks/agent-generated-titles.md index db9d662997..5c4b37ef59 100644 --- a/docs/specs/tasks/agent-generated-titles.md +++ b/docs/specs/tasks/agent-generated-titles.md @@ -88,8 +88,8 @@ 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. +repository-scoped task-environment row and the corresponding running-executor branch snapshots used +by restart and resume. The task workspace directory is not renamed. ## API surface diff --git a/docs/specs/tasks/runtime-cleanup.md b/docs/specs/tasks/runtime-cleanup.md index 5b390005c7..85b0a90595 100644 --- a/docs/specs/tasks/runtime-cleanup.md +++ b/docs/specs/tasks/runtime-cleanup.md @@ -7,6 +7,8 @@ owner: cfl # Task Runtime Cleanup +Decision: [ADR-2026-08-08-task-owned-worktree-lifetime](../../decisions/2026-08-08-task-owned-worktree-lifetime.md) + ## Why Operators archive and delete completed tasks to keep the board usable and the @@ -35,6 +37,15 @@ worktrees, or executor rows behind and the machine slowly runs out of memory. duration of that session. Cleanup can stop the current task's runtime rows, but must defer destructive worktree/container/sandbox teardown until no other active task session references the environment. +- A task owns its materialized workspace independently of its sessions. Deleting + any session, including the task's last session, removes only that session and + its workspace references. It does not remove task workspace directories, Git + worktree registrations, or branches. +- A task with zero sessions retains its materialized workspace so a later session + can reuse the same files and Git state. +- Physical workspace cleanup is initiated only by task lifecycle operations: + archive, delete, cascade archive/delete, workspace delete, quick-chat expiry, + or explicit task-environment reset. Session deletion is not a cleanup trigger. - Agent subprocess shutdown kills the whole agent process group when graceful shutdown does not finish within the configured stop timeout. - Agent subprocess shutdown does not treat the command leader exiting as @@ -127,6 +138,34 @@ not imply runtime resources have been released. Cleanup code must not use termin session state as a reason to skip runtime teardown when an `executors_running` row exists. +### `task_environments` and `task_environment_repos` + +`task_environments.task_id` is the canonical workspace owner. +`task_environment_repos` records the ordered repositories in that environment +and is the single source of physical worktree identity, path, branch, status, and +lifecycle timestamps. Task cleanup queries repository rows through the owning +environment's `task_id`; it never needs a session row. + +Sessions refer to the complete shared workspace through +`task_sessions.task_environment_id`. The legacy `task_session_worktrees` table is +redundant and is removed after a transactional SQLite/PostgreSQL backfill into +`task_environment_repos`. The same migration removes deprecated flat +`repository_id`, `worktree_id`, `worktree_path`, and `worktree_branch` columns +from `task_environments`. Runtime code, APIs, storage inventory, and cleanup use +only the normalized schema; there is no permanent dual-read or dual-write path. + +The migration fails closed when existing environment, session, repository, +identity, or path data cannot be normalized to exactly one environment owner. +Fresh databases are created directly in the final schema. + +Normalization uses a dedicated error-returning migration under the database +writer/migration lock. Shadow tables are populated and compared against the full +legacy worktree inventory before legacy schema is dropped. Ownership, uniqueness, +foreign keys, row counts, and the final schema are validated before commit. Any +failure rolls back the entire cutover. SQLite additionally requires the existing +fatal pre-upgrade snapshot; PostgreSQL uses transactional DDL under an advisory +migration lock. The migration never performs filesystem or Git cleanup. + ### `task_resource_cleanup_jobs` `task_resource_cleanup_jobs` is the durable task-lifecycle cleanup intent. It has @@ -138,12 +177,22 @@ reuses the same cleanup job. `attempts` counts successful worker claims. A terminal `failed` row retains its final error and completion timestamp for diagnosis but is not selected by the automatic worker. +A prepared task-lifecycle cleanup job also acts as a durable creation barrier. +The barrier is reserved before resource inventory is captured. Session creation +and physical worktree persistence serialize against the task row and refuse new +ownership while archive/delete preparation is active. The barrier transaction +never holds filesystem, target-path, or repository Git locks. + ## API Surface No new user-facing HTTP or WebSocket action is required. Existing task archive, task delete, session stop, and backend startup behavior gain stronger cleanup guarantees. +`session.delete` keeps its existing request and response contract. Success means +the session row is gone. It does not mean the task workspace was cleaned, and it +never enqueues task resource cleanup. + Internal contracts: ```go @@ -252,6 +301,16 @@ The durable cleanup job wraps that resource lifecycle: - If an archived task is unarchived before cleanup completes, the worker cancels remaining archive cleanup. Already completed resource removal remains valid and the unarchive branch-recovery flow recreates the environment when possible. +- If deleting a session fails, no physical workspace operation has been attempted; + the task-owned worktree record remains authoritative regardless of whether the + session mutation committed. +- If a session or worktree creation races task archive/delete preparation, the + task lifecycle barrier wins before cleanup inventory is finalized. The creation + is rejected or its uncommitted materialization is compensated, and the cleanup + snapshot cannot omit a newly owned worktree. +- If task-owned worktree backfill encounters conflicting ownership or path data, + startup fails closed with a diagnostic instead of choosing a row that could + authorize deletion of another task's workspace. ## Persistence Guarantees @@ -270,6 +329,13 @@ The durable cleanup job wraps that resource lifecycle: resume automatically. - Cleanup snapshots needed after task deletion survive without foreign-keyed task, session, environment, or worktree rows. +- A `task_environment_repos` row, its directory, Git registration, branch, and + uncommitted files survive deletion of every session for the task and survive + backend restart. They remain discoverable through + `task_environments.task_id` until task lifecycle cleanup takes ownership. +- Storage inventory protects paths from task environment repository rows, not + only paths referenced by live sessions, so a zero-session task workspace is + not classified as orphaned. - Historical worktree rows for archived tasks remain available to unarchive branch recovery even after their on-disk directories are removed. - Orphaned OS processes without any durable `executors_running` row are outside @@ -278,6 +344,28 @@ The durable cleanup job wraps that resource lifecycle: ## Scenarios +- **GIVEN** a task with one stopped session, a registered worktree, and + uncommitted files, **WHEN** the session is deleted, **THEN** the task remains + with zero sessions and the directory, Git registration, branch, and files are + unchanged. +- **GIVEN** a task whose last session was deleted, **WHEN** the backend restarts + and a new session is created, **THEN** the session reuses the task-owned + workspace and observes the preserved files. +- **GIVEN** two sessions referencing the same task worktree, **WHEN** either + session is deleted, **THEN** the remaining session continues using the same + directory and no filesystem or Git cleanup command runs. +- **GIVEN** a session has been deleted and the backend restarted, **WHEN** its + task is archived, **THEN** durable asynchronous task cleanup still discovers + the task-owned worktree by `task_id` and removes it unless another task holds + a protected shared reference. +- **GIVEN** a session has been deleted and the backend restarted, **WHEN** its + task is deleted, **THEN** the cleanup job snapshot retains the worktree handles + after task-row cascades and retries filesystem or Git failures after restart. +- **GIVEN** task cleanup preparation is racing a new session/worktree launch, + **WHEN** the lifecycle barrier is reserved, **THEN** no resource created after + the barrier can be omitted from the cleanup inventory or survive as an + untracked directory. + - **GIVEN** a task has a `WAITING_FOR_INPUT` session and an `executors_running` row, **WHEN** the task is archived, **THEN** the runtime is stopped by `agent_execution_id` before the row and worktrees are removed. @@ -379,6 +467,9 @@ The durable cleanup job wraps that resource lifecycle: - A general-purpose OS process sweeper that kills every process named `codex-acp`, `claude-acp`, or `opencode`. - UI changes for showing runtime cleanup failures. +- Per-session warnings about uncommitted or unpushed workspace state; ordinary + session deletion preserves that state. Destructive warnings remain attached + to task archive/delete/reset surfaces. - A user-facing action to replay a terminal failed cleanup job. - New user-facing archive/delete controls. - Changing the task/session state model beyond the cleanup guarantees described