fix(task): preserve task-owned worktrees across session deletion (#2456)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ţĩţĺē",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user