fix(task): preserve task-owned worktrees across session deletion (#2456)

This commit is contained in:
Carlos Florêncio
2026-08-09 12:05:48 +01:00
committed by GitHub
parent 3cb85a0d87
commit 2832170eb4
115 changed files with 5656 additions and 1948 deletions
@@ -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
@@ -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)
@@ -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)
}
}
@@ -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.
@@ -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 {
@@ -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)
}
@@ -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
}
@@ -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
+16 -5
View File
@@ -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.
@@ -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: ""},
},
@@ -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)
}
@@ -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 == "" {
@@ -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)
}
@@ -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
@@ -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)
}
}
@@ -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
@@ -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 == "" {
@@ -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)
@@ -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 ||
@@ -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
@@ -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
@@ -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",
@@ -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)
@@ -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
@@ -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")
@@ -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 {
@@ -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)",
@@ -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
@@ -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)
}
}
@@ -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)
}
@@ -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
}
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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,
@@ -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)
+31 -30
View File
@@ -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
}
@@ -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
+85 -59
View File
@@ -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
}
@@ -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)
@@ -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")
@@ -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
@@ -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,
@@ -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.
@@ -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 {
@@ -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 <root>/.kandev/tasks/<name>/<repo>; 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)
}
}
@@ -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)
}
}
@@ -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.
@@ -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.
@@ -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 {
@@ -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()
@@ -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
}
@@ -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)
}
}
@@ -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 {
@@ -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",
@@ -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
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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
}
@@ -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")
}
}
@@ -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
}
@@ -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
@@ -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)
@@ -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)
@@ -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 {
@@ -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",
@@ -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
}
@@ -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.
@@ -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
}
@@ -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)
@@ -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 {
@@ -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)
}
@@ -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)
@@ -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
@@ -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)
}
+12
View File
@@ -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.
@@ -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),
@@ -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)
}
+31 -14
View File
@@ -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))
+270 -315
View File
@@ -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)
+40 -8
View File
@@ -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",
+13 -2
View File
@@ -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
@@ -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(<MobileSessionsPicker taskId={TASK_ID} sessionId={SESSION_A} fullWidth />);
// 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();
});
});
@@ -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(
<DeleteSessionDialog
open
onOpenChange={vi.fn()}
isPrimary={false}
sessionCount={1}
onConfirm={vi.fn()}
/>,
);
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(
<DeleteSessionDialog
open
onOpenChange={onOpenChange}
isPrimary={false}
sessionCount={2}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
+7
View File
@@ -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;
@@ -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" },
@@ -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" },
@@ -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 });
@@ -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);
});
});
@@ -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;
@@ -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;
@@ -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);
}
});
});
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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": "ţĩţĺē",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "标题",
@@ -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).
@@ -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.
@@ -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.
+1
View File
@@ -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 |

Some files were not shown because too many files have changed in this diff Show More