package sqlite import ( "context" "database/sql" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/recoveryclaim" ) const taskEnvironmentOwnershipQuery = `SELECT task_id, ownership_generation FROM task_environments WHERE id = ?` // CreateTaskEnvironment creates a new task environment record. // If env.Repos is non-empty, the per-repo rows are inserted in the same transaction. func (r *Repository) CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { if env.ID == "" { env.ID = uuid.New().String() } // A creating worktree environment has no path until its elected owner // materializes it. Ready/stopped environments still require one. // Without it, // GetOrEnsureExecutionForEnvironment returns ErrSessionWorkspaceNotReady // forever and the env terminal handler 503s. Reject at the boundary // instead of letting a corrupt row land. if env.ExecutorType == string(models.ExecutorTypeWorktree) && env.WorkspacePath == "" && env.Status != models.TaskEnvironmentStatusCreating { return fmt.Errorf("create task environment: worktree-mode env requires workspace_path (task=%s)", env.TaskID) } now := time.Now().UTC() env.CreatedAt = now env.UpdatedAt = now if env.OwnershipGeneration == 0 { env.OwnershipGeneration = 1 } 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 } // A new environment for a task must not be created while one of that task's // existing environments is under recovery. Otherwise a concurrent launch // could escape the selected-environment authority by creating a fresh row. if err := recoveryclaim.EnsureTaskAvailableTx(ctx, r.db, tx, env.TaskID); err != nil { return err } // A ready environment with zero repo rows is indistinguishable from "the // canonical inventory was never captured" and permanently bricks reuse // (validateReuseEnvironmentInventory refuses every later launch). Reject // at the boundary instead of trusting the caller: only a task with no // repositories configured at all may legitimately have none. if env.Status == models.TaskEnvironmentStatusReady && len(env.Repos) == 0 { hasRepos, err := r.taskHasRepositoriesTx(ctx, tx, env.TaskID) if err != nil { return err } if hasRepos { return fmt.Errorf("create task environment: ready status requires repository inventory for repo-backed task (task=%s)", env.TaskID) } } if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_environments ( id, task_id, ownership_generation, executor_type, executor_id, executor_profile_id, control_port, status, materialization_session_id, workspace_path, container_id, container_bootstrap_nonce_secret_id, container_control_auth_token_secret_id, sandbox_id, task_dir_name, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), env.ID, env.TaskID, env.OwnershipGeneration, env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(env.Status), env.MaterializationSessionID, env.WorkspacePath, env.ContainerID, env.ContainerBootstrapNonceSecretID, env.ContainerControlAuthTokenSecretID, env.SandboxID, env.TaskDirName, env.CreatedAt, env.UpdatedAt, ); err != nil { return err } for _, repo := range env.Repos { repo.TaskEnvironmentID = env.ID if err := r.insertTaskEnvironmentRepoTx(ctx, tx, repo); err != nil { return err } } return tx.Commit() } // GetTaskEnvironment retrieves a task environment by ID, including per-repo rows. func (r *Repository) GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, task_id, ownership_generation, executor_type, executor_id, executor_profile_id, control_port, status, materialization_session_id, workspace_path, container_id, COALESCE(container_bootstrap_nonce_secret_id, ''), COALESCE(container_control_auth_token_secret_id, ''), sandbox_id, COALESCE(task_dir_name, ''), created_at, updated_at FROM task_environments WHERE id = ? `), id).Scan( &env.ID, &env.TaskID, &env.OwnershipGeneration, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.ControlPort, &status, &env.MaterializationSessionID, &env.WorkspacePath, &env.ContainerID, &env.ContainerBootstrapNonceSecretID, &env.ContainerControlAuthTokenSecretID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, id) } if err != nil { return nil, err } env.Status = models.TaskEnvironmentStatus(status) repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) if err != nil { return nil, err } env.Repos = repos return env, nil } // GetTaskEnvironmentByTaskID retrieves the active task environment for a task, // including per-repo rows. func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { env := &models.TaskEnvironment{} var status string err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, task_id, ownership_generation, executor_type, executor_id, executor_profile_id, control_port, status, materialization_session_id, workspace_path, container_id, COALESCE(container_bootstrap_nonce_secret_id, ''), COALESCE(container_control_auth_token_secret_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.OwnershipGeneration, &env.ExecutorType, &env.ExecutorID, &env.ExecutorProfileID, &env.ControlPort, &status, &env.MaterializationSessionID, &env.WorkspacePath, &env.ContainerID, &env.ContainerBootstrapNonceSecretID, &env.ContainerControlAuthTokenSecretID, &env.SandboxID, &env.TaskDirName, &env.CreatedAt, &env.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil // No environment yet — not an error } if err != nil { return nil, err } env.Status = models.TaskEnvironmentStatus(status) repos, err := r.ListTaskEnvironmentRepos(ctx, env.ID) if err != nil { return nil, err } env.Repos = repos return env, nil } // GetTaskEnvironmentExistenceByTaskIDs reports, for each of taskIDs, whether // any task_environments row exists — the same unconditional presence check // runnerHasEnvironment makes for one task, batched behind a single IN-clause // query for a projection covering many. func (r *Repository) GetTaskEnvironmentExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) { return r.batchedTaskIDExistence(ctx, "task_environments", taskIDs) } // UpdateTaskEnvironment updates an existing task environment. // Per-repo rows are not touched; use the TaskEnvironmentRepo CRUD methods. func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { // Creating and failed worktree environments can lack a path because // materialization can fail before one exists. Reusable states require it. if env.ExecutorType == string(models.ExecutorTypeWorktree) && env.WorkspacePath == "" && env.Status != models.TaskEnvironmentStatusCreating && env.Status != models.TaskEnvironmentStatusFailed { return fmt.Errorf("update task environment: worktree-mode env requires workspace_path (id=%s)", env.ID) } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, env.ID); err != nil { return err } if env.Status == models.TaskEnvironmentStatusReady { if err := r.validateReadyTaskEnvironment(ctx, tx, env.ID); err != nil { return err } } env.UpdatedAt = time.Now().UTC() result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET executor_type = ?, executor_id = ?, executor_profile_id = ?, control_port = ?, status = ?, materialization_session_id = ?, workspace_path = ?, container_id = ?, container_bootstrap_nonce_secret_id = ?, container_control_auth_token_secret_id = ?, sandbox_id = ?, task_dir_name = COALESCE(NULLIF(task_dir_name, ''), NULLIF(?, ''), task_dir_name), updated_at = ? WHERE id = ? `), env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(env.Status), env.MaterializationSessionID, env.WorkspacePath, env.ContainerID, env.ContainerBootstrapNonceSecretID, env.ContainerControlAuthTokenSecretID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, ) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, env.ID) } return tx.Commit() } // SetTaskEnvironmentTaskDirNameIfEmpty claims the stable task-root identity // without rewriting the rest of an environment row. The compare-and-set keeps // concurrent inherited launches from choosing different physical roots. func (r *Repository) SetTaskEnvironmentTaskDirNameIfEmpty(ctx context.Context, environmentID, taskDirName string) (bool, error) { if environmentID == "" || taskDirName == "" { return false, fmt.Errorf("set task environment task directory name: environment and task directory name are required") } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return false, err } defer func() { _ = tx.Rollback() }() taskID, _, err := r.taskEnvironmentStateTx(ctx, tx, environmentID) if err != nil { return false, err } if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { return false, err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID); err != nil { return false, err } result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET task_dir_name = ?, updated_at = ? WHERE id = ? AND COALESCE(task_dir_name, '') = '' `), taskDirName, time.Now().UTC(), environmentID) if err != nil { return false, err } claimed, _ := result.RowsAffected() if err := tx.Commit(); err != nil { return false, err } return claimed == 1, nil } func (r *Repository) validateReadyTaskEnvironment(ctx context.Context, tx *sqlx.Tx, environmentID string) error { taskID, currentStatus, err := r.taskEnvironmentStateTx(ctx, tx, environmentID) if err != nil { return err } if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { return err } // Workspace-source materialization updates an already-ready environment // after it adds a folder or repository. That update does not publish a new // ready state, so it must remain compatible with legacy environments whose // canonical inventory is still empty. Launch persistence separately rejects // that state before it calls this repository method. if currentStatus == models.TaskEnvironmentStatusReady { return nil } hasRepos, err := r.taskHasRepositoriesTx(ctx, tx, taskID) if err != nil || !hasRepos { return err } var inventoryCount int if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(1) FROM task_environment_repos WHERE task_environment_id = ?`), environmentID).Scan(&inventoryCount); err != nil { return err } if inventoryCount == 0 { return fmt.Errorf("update task environment: ready status requires repository inventory for repo-backed task (id=%s)", environmentID) } return nil } func (r *Repository) taskEnvironmentStateTx(ctx context.Context, tx *sqlx.Tx, environmentID string) (string, models.TaskEnvironmentStatus, error) { var taskID string var status string err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id, status FROM task_environments WHERE id = ?`), environmentID).Scan(&taskID, &status) if err == sql.ErrNoRows { return "", "", fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, environmentID) } return taskID, models.TaskEnvironmentStatus(status), err } // FinalizeTaskEnvironmentMaterialization writes the complete canonical // repository inventory and transitions the elected creating environment to // ready in one transaction. The materializer ID prevents a sibling session // from publishing another session's workspace. func (r *Repository) FinalizeTaskEnvironmentMaterialization( ctx context.Context, env *models.TaskEnvironment, repos []*models.TaskEnvironmentRepo, materializationSessionID string, ) error { if env == nil || env.ID == "" || materializationSessionID == "" { return fmt.Errorf("finalize task environment: environment and materializer are required") } if env.ExecutorType == string(models.ExecutorTypeWorktree) && env.WorkspacePath == "" { return fmt.Errorf("finalize task environment: worktree-mode env requires workspace_path (id=%s)", env.ID) } 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 = ?`), env.ID).Scan(&taskID); err != nil { return fmt.Errorf("finalize task environment: resolve owner: %w", err) } if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, env.ID); err != nil { return err } // See the matching guard in CreateTaskEnvironment: a materializer that // publishes ready with an empty inventory (e.g. because its prepare step // failed and produced no repos) must not be allowed to land, or the // environment becomes permanently unreusable. if len(repos) == 0 { hasRepos, err := r.taskHasRepositoriesTx(ctx, tx, taskID) if err != nil { return err } if hasRepos { return fmt.Errorf("finalize task environment: ready status requires repository inventory for repo-backed task (id=%s)", env.ID) } } if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_environment_repos WHERE task_environment_id = ?`), env.ID); err != nil { return err } for _, repo := range repos { repo.TaskEnvironmentID = env.ID if err := r.insertTaskEnvironmentRepoTx(ctx, tx, repo); err != nil { return err } } env.UpdatedAt = time.Now().UTC() result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET executor_type = ?, executor_id = ?, executor_profile_id = ?, control_port = ?, status = ?, materialization_session_id = '', workspace_path = ?, container_id = ?, container_bootstrap_nonce_secret_id = ?, container_control_auth_token_secret_id = ?, sandbox_id = ?, task_dir_name = COALESCE(NULLIF(task_dir_name, ''), NULLIF(?, ''), task_dir_name), updated_at = ? WHERE id = ? AND status = ? AND materialization_session_id = ? `), env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(models.TaskEnvironmentStatusReady), env.WorkspacePath, env.ContainerID, env.ContainerBootstrapNonceSecretID, env.ContainerControlAuthTokenSecretID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, string(models.TaskEnvironmentStatusCreating), materializationSessionID, ) if err != nil { return err } if rows, _ := result.RowsAffected(); rows != 1 { return fmt.Errorf("finalize task environment: materialization claim was not current") } return tx.Commit() } // PersistTaskEnvironmentTransition atomically updates an existing environment // and reconciles its repository inventory. A transition must not leave the old // executor attached to a partially replaced inventory when any write fails. // When replacePhysical is true, active rows absent from repos become deleted // tombstones so old worktree paths cannot re-enter a later launch. func (r *Repository) PersistTaskEnvironmentTransition( ctx context.Context, env *models.TaskEnvironment, repos []*models.TaskEnvironmentRepo, replacePhysical bool, ) error { if env == nil || env.ID == "" { return fmt.Errorf("persist task environment transition: environment is required") } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() taskID, _, err := r.taskEnvironmentStateTx(ctx, tx, env.ID) if err != nil { return err } if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, env.ID); err != nil { return err } existing, err := listTaskEnvironmentReposTx(ctx, r, tx, env.ID) if err != nil { return err } if err := r.validateTaskEnvironmentTransitionWorkspace(ctx, tx, taskID, env, existing); err != nil { return err } if err := r.reconcileTaskEnvironmentReposTx(ctx, tx, env.ID, existing, repos, replacePhysical); err != nil { return err } if err := r.validateTaskEnvironmentTransitionReady(ctx, tx, env.ID, env.Status); err != nil { return err } if err := r.updateTaskEnvironmentTransitionTx(ctx, tx, env); err != nil { return err } return tx.Commit() } func (r *Repository) validateTaskEnvironmentTransitionWorkspace( ctx context.Context, tx *sqlx.Tx, taskID string, env *models.TaskEnvironment, existing []*models.TaskEnvironmentRepo, ) error { if env.ExecutorType != string(models.ExecutorTypeWorktree) || env.WorkspacePath != "" { return nil } hasRepos, err := r.taskHasRepositoriesTx(ctx, tx, taskID) if err != nil { return err } if hasRepos || len(existing) > 0 { return fmt.Errorf("persist task environment transition: worktree-mode env requires workspace_path (id=%s)", env.ID) } return nil } func (r *Repository) validateTaskEnvironmentTransitionReady( ctx context.Context, tx *sqlx.Tx, environmentID string, status models.TaskEnvironmentStatus, ) error { if status != models.TaskEnvironmentStatusReady { return nil } return r.validateReadyTaskEnvironment(ctx, tx, environmentID) } func (r *Repository) updateTaskEnvironmentTransitionTx( ctx context.Context, tx *sqlx.Tx, env *models.TaskEnvironment, ) error { env.UpdatedAt = time.Now().UTC() result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET executor_type = ?, executor_id = ?, executor_profile_id = ?, control_port = ?, status = ?, materialization_session_id = ?, workspace_path = ?, container_id = ?, container_bootstrap_nonce_secret_id = ?, container_control_auth_token_secret_id = ?, sandbox_id = ?, task_dir_name = COALESCE(NULLIF(task_dir_name, ''), NULLIF(?, ''), task_dir_name), updated_at = ? WHERE id = ? `), env.ExecutorType, env.ExecutorID, env.ExecutorProfileID, env.ControlPort, string(env.Status), env.MaterializationSessionID, env.WorkspacePath, env.ContainerID, env.ContainerBootstrapNonceSecretID, env.ContainerControlAuthTokenSecretID, env.SandboxID, env.TaskDirName, env.UpdatedAt, env.ID, ) if err != nil { return err } if rows, _ := result.RowsAffected(); rows != 1 { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, env.ID) } return nil } func listTaskEnvironmentReposTx(ctx context.Context, r *Repository, tx *sqlx.Tx, envID string) ([]*models.TaskEnvironmentRepo, error) { rows, err := tx.QueryxContext(ctx, r.db.Rebind(` SELECT `+envRepoSelectCols+` FROM task_environment_repos ter WHERE ter.task_environment_id = ? ORDER BY ter.position ASC, ter.created_at ASC `), envID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var result []*models.TaskEnvironmentRepo for rows.Next() { row, err := scanEnvRepoRow(rows) if err != nil { return nil, err } result = append(result, row) } return result, rows.Err() } func (r *Repository) reconcileTaskEnvironmentReposTx( ctx context.Context, tx *sqlx.Tx, envID string, existing, repos []*models.TaskEnvironmentRepo, replacePhysical bool, ) error { byKey, legacyFlatByRepo, soleByRepo := indexTaskEnvironmentReposTx(existing) matched := make(map[string]struct{}, len(repos)) for position, incoming := range repos { row := findTaskEnvironmentRepoTransition(byKey, legacyFlatByRepo, incoming) if row == nil && !replacePhysical { row = takeSoleUntrackedTaskEnvironmentRepo(byKey, soleByRepo, incoming) } if row == nil { if incoming == nil || incoming.RepositoryID == "" { continue } incoming.TaskEnvironmentID = envID incoming.Position = position if err := r.insertTaskEnvironmentRepoTx(ctx, tx, incoming); err != nil { return err } continue } matched[row.ID] = struct{}{} if err := r.updateTaskEnvironmentRepoTransitionTx(ctx, tx, row, incoming, position, replacePhysical); err != nil { return err } } if !replacePhysical { return nil } return r.tombstoneOmittedTaskEnvironmentReposTx(ctx, tx, existing, matched) } func indexTaskEnvironmentReposTx(rows []*models.TaskEnvironmentRepo) ( map[string]*models.TaskEnvironmentRepo, map[string]*models.TaskEnvironmentRepo, map[string]*models.TaskEnvironmentRepo, ) { byKey := make(map[string]*models.TaskEnvironmentRepo, len(rows)) legacyFlatByRepo := make(map[string]*models.TaskEnvironmentRepo) soleByRepo := make(map[string]*models.TaskEnvironmentRepo) ambiguousRepo := make(map[string]struct{}) for _, row := range rows { if row == nil { continue } byKey[row.RepositoryID+"\x00"+row.BranchSlug] = row if row.RepositoryID == "" { continue } if row.BranchSlug == "" { legacyFlatByRepo[row.RepositoryID] = row } if row.DeletedAt != nil || row.Status == "failed" || row.Status == worktreeRepoStatusDeleted { continue } if _, ambiguous := ambiguousRepo[row.RepositoryID]; ambiguous { continue } if _, seen := soleByRepo[row.RepositoryID]; seen { delete(soleByRepo, row.RepositoryID) ambiguousRepo[row.RepositoryID] = struct{}{} continue } soleByRepo[row.RepositoryID] = row } return byKey, legacyFlatByRepo, soleByRepo } func findTaskEnvironmentRepoTransition( byKey map[string]*models.TaskEnvironmentRepo, legacyFlatByRepo map[string]*models.TaskEnvironmentRepo, incoming *models.TaskEnvironmentRepo, ) *models.TaskEnvironmentRepo { if incoming == nil || incoming.RepositoryID == "" { return nil } key := incoming.RepositoryID + "\x00" + incoming.BranchSlug row := byKey[key] if row == nil && incoming.BranchSlug != "" { row = legacyFlatByRepo[incoming.RepositoryID] if row != nil { delete(legacyFlatByRepo, incoming.RepositoryID) byKey[key] = row } } return row } // takeSoleUntrackedTaskEnvironmentRepo reuses the only active inventory row // for a repository when a non-replacement transition has no branch identity. // Local resumes intentionally do not select or switch branches, so the // existing canonical row is the only safe branch identity to retain. func takeSoleUntrackedTaskEnvironmentRepo( byKey, soleByRepo map[string]*models.TaskEnvironmentRepo, incoming *models.TaskEnvironmentRepo, ) *models.TaskEnvironmentRepo { if incoming == nil || incoming.RepositoryID == "" || incoming.BranchSlug != "" { return nil } row := soleByRepo[incoming.RepositoryID] if row != nil { delete(soleByRepo, incoming.RepositoryID) delete(byKey, row.RepositoryID+"\x00"+row.BranchSlug) byKey[incoming.RepositoryID+"\x00"] = row } return row } func (r *Repository) updateTaskEnvironmentRepoTransitionTx( ctx context.Context, tx *sqlx.Tx, row, incoming *models.TaskEnvironmentRepo, position int, replacePhysical bool, ) error { // An incoming transition with untracked branch identity (a non-worktree // local/local_pc resume never stamps req.BaseBranch) must not overwrite // a row's already-known real branch with an empty one; preserve it. if replacePhysical || incoming.BranchSlug != "" || row.BranchSlug == "" { row.BranchSlug = incoming.BranchSlug } if replacePhysical || incoming.WorktreeID != "" { row.WorktreeID = incoming.WorktreeID row.WorktreePath = incoming.WorktreePath row.WorktreeBranch = incoming.WorktreeBranch } if incoming.WorktreeBranchOwner != "" || incoming.WorktreeID == "" { row.WorktreeBranchOwner = incoming.WorktreeBranchOwner } if incoming.WorktreeIntegrationRef != "" || incoming.WorktreeID == "" || replacePhysical { row.WorktreeIntegrationRef = incoming.WorktreeIntegrationRef } row.Position = position row.ErrorMessage = incoming.ErrorMessage // A later successful transition can recreate a slot that an earlier // replace-mode transition tombstoned. Rebind the existing row to the // active inventory instead of leaving the matching key permanently // excluded from reuse. if replacePhysical { row.Status = worktreeRepoStatusActive row.DeletedAt = nil row.WorktreeRecoveryHeadSHA = "" row.WorktreeBranchCompactedAt = nil } row.UpdatedAt = time.Now().UTC() _, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environment_repos SET branch_slug = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, worktree_branch_owner = ?, worktree_integration_ref = ?, worktree_recovery_head_sha = ?, worktree_branch_compacted_at = ?, position = ?, error_message = ?, status = ?, deleted_at = ?, updated_at = ? WHERE id = ? `), row.BranchSlug, row.WorktreeID, row.WorktreePath, row.WorktreeBranch, row.WorktreeBranchOwner, row.WorktreeIntegrationRef, row.WorktreeRecoveryHeadSHA, row.WorktreeBranchCompactedAt, row.Position, row.ErrorMessage, row.Status, row.DeletedAt, row.UpdatedAt, row.ID) return err } func (r *Repository) tombstoneOmittedTaskEnvironmentReposTx( ctx context.Context, tx *sqlx.Tx, existing []*models.TaskEnvironmentRepo, matched map[string]struct{}, ) error { deletedAt := time.Now().UTC() for _, row := range existing { if row == nil || row.DeletedAt != nil || row.Status == worktreeRepoStatusDeleted { continue } if _, ok := matched[row.ID]; ok { continue } if _, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environment_repos SET status = ?, deleted_at = ?, updated_at = ? WHERE id = ? `), worktreeRepoStatusDeleted, deletedAt, deletedAt, row.ID); err != nil { return err } } return nil } // taskHasRepositoriesTx reports whether a task has any task_repositories // rows, i.e. whether it is repo-backed and therefore expected to carry a // non-empty canonical inventory whenever its environment is ready. func (r *Repository) taskHasRepositoriesTx(ctx context.Context, tx *sqlx.Tx, taskID string) (bool, error) { var count int if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT COUNT(1) FROM task_repositories WHERE task_id = ?`), taskID).Scan(&count); err != nil { return false, err } return count > 0, nil } // TransferTaskEnvironmentOwnership moves an environment only when the // caller's owner-generation snapshot is still current. func (r *Repository) TransferTaskEnvironmentOwnership( ctx context.Context, envID, expectedTaskID string, expectedGeneration int64, taskID string, ) error { return r.transferTaskEnvironmentOwnership(ctx, envID, expectedTaskID, expectedGeneration, taskID) } func (r *Repository) transferTaskEnvironmentOwnership( ctx context.Context, envID, expectedTaskID string, expectedGeneration int64, taskID string, ) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if expectedTaskID != "" { if err := r.lockTaskRowInTx(ctx, tx, expectedTaskID); err != nil { return err } } query := taskEnvironmentOwnershipQuery if dialect.IsPostgres(r.db.DriverName()) { query += ` FOR UPDATE` } var currentTaskID string var currentGeneration int64 if err := tx.QueryRowContext(ctx, r.db.Rebind(query), envID).Scan(¤tTaskID, ¤tGeneration); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, envID) } return err } if currentTaskID != expectedTaskID || currentGeneration != expectedGeneration { return fmt.Errorf("%w: environment %s is owned by %s at generation %d", ErrTaskEnvironmentOwnershipChanged, envID, currentTaskID, currentGeneration) } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, envID); err != nil { return err } if currentTaskID == taskID { return tx.Commit() } if err := r.taskCleanupBarrierLocked(ctx, tx, currentTaskID); err != nil { return err } result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environments SET task_id = ?, ownership_generation = ownership_generation + 1, updated_at = ? WHERE id = ? `), taskID, time.Now().UTC(), envID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, envID) } return tx.Commit() } // ClaimTaskEnvironmentReset reserves destructive environment reset behind the // same task cleanup barrier used by lifecycle cleanup. The prepared barrier // remains in place while external resources are torn down, so ownership // transfers cannot race the reset's snapshot. func (r *Repository) ClaimTaskEnvironmentReset( ctx context.Context, taskID, environmentID string, expectedGeneration int64, operationID string, ) (string, error) { if taskID == "" || environmentID == "" || operationID == "" { return "", errors.New("task environment reset claim: task, environment, and operation ids are required") } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return "", err } defer func() { _ = tx.Rollback() }() if err := r.lockTaskRowInTx(ctx, tx, taskID); err != nil { return "", err } if err := r.taskCleanupBarrierLocked(ctx, tx, taskID); err != nil { return "", err } now := time.Now().UTC() jobID := uuid.NewString() if _, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_resource_cleanup_jobs ( id, operation_id, task_id, trigger, state, resource_snapshot, attempts, next_attempt_at, last_error, created_at, updated_at, completed_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), jobID, operationID, taskID, models.TaskResourceCleanupTriggerReconcile, models.TaskResourceCleanupStatePrepared, `{}`, 0, nil, "", now, now, nil); err != nil { return "", err } query := taskEnvironmentOwnershipQuery if dialect.IsPostgres(r.db.DriverName()) { query += ` FOR UPDATE` } var ownerTaskID string var generation int64 if err := tx.QueryRowContext(ctx, r.db.Rebind(query), environmentID).Scan(&ownerTaskID, &generation); err != nil { if errors.Is(err, sql.ErrNoRows) { return "", fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, environmentID) } return "", err } if ownerTaskID != taskID || generation != expectedGeneration { return "", fmt.Errorf("%w: environment %s is owned by %s at generation %d", ErrTaskEnvironmentOwnershipChanged, environmentID, ownerTaskID, generation) } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return jobID, nil } // ReleaseTaskEnvironmentReset removes a reset's durable cleanup barrier. func (r *Repository) ReleaseTaskEnvironmentReset( ctx context.Context, jobID string, state models.TaskResourceCleanupState, errStr string, ) error { return r.CompleteTaskResourceCleanupJob(ctx, jobID, state, errStr, nil) } // DeleteTaskEnvironment deletes a task environment by ID. // Per-repo rows are removed via ON DELETE CASCADE. func (r *Repository) DeleteTaskEnvironment(ctx context.Context, id string) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var existingID string if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT id FROM task_environments WHERE id = ?`), id).Scan(&existingID); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, id) } return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, id); err != nil { return err } if err := r.ensureKubernetesInventoryAbsent(ctx, tx, id); err != nil { return err } result, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_environments WHERE id = ?`), id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("%w: %s", ErrTaskEnvironmentNotFound, id) } return tx.Commit() } // DeleteTaskEnvironmentsByTask deletes all task environments for a given task. func (r *Repository) DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if err := recoveryclaim.EnsureTaskAvailableTx(ctx, r.db, tx, taskID); err != nil { return err } rows, err := tx.QueryxContext(ctx, r.db.Rebind(`SELECT id FROM task_environments WHERE task_id = ?`), taskID) if err != nil { return err } var environmentIDs []string for rows.Next() { var environmentID string if err := rows.Scan(&environmentID); err != nil { _ = rows.Close() return err } environmentIDs = append(environmentIDs, environmentID) } if err := rows.Close(); err != nil { return err } if err := rows.Err(); err != nil { return err } for _, environmentID := range environmentIDs { if err := r.ensureKubernetesInventoryAbsent(ctx, tx, environmentID); err != nil { return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID); err != nil { return err } } if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_environments WHERE task_id = ?`), taskID); err != nil { return err } return tx.Commit() } // 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") } 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 err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, repo.TaskEnvironmentID); 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 } 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, worktree_branch_owner, worktree_integration_ref, worktree_recovery_head_sha, worktree_branch_compacted_at, position, error_message, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.WorktreeBranchOwner, repo.WorktreeIntegrationRef, repo.WorktreeRecoveryHeadSHA, repo.WorktreeBranchCompactedAt, 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 *sqlx.Tx, repo *models.TaskEnvironmentRepo) error { if repo.RepositoryID == "" { return fmt.Errorf("repository_id is required") } 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 := 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, worktree_branch_owner, worktree_integration_ref, worktree_recovery_head_sha, worktree_branch_compacted_at, position, error_message, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), repo.ID, repo.TaskEnvironmentID, repo.RepositoryID, repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.WorktreeBranchOwner, repo.WorktreeIntegrationRef, repo.WorktreeRecoveryHeadSHA, repo.WorktreeBranchCompactedAt, repo.Position, repo.ErrorMessage, repo.Status, repo.CreatedAt, repo.UpdatedAt, ) return err } // ListTaskEnvironmentRepos returns all per-repo rows for an environment, ordered by position. func (r *Repository) ListTaskEnvironmentRepos(ctx context.Context, envID string) ([]*models.TaskEnvironmentRepo, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, task_environment_id, repository_id, COALESCE(branch_slug, ''), worktree_id, worktree_path, worktree_branch, COALESCE(worktree_branch_owner, 'unknown'), COALESCE(worktree_integration_ref, ''), COALESCE(worktree_recovery_head_sha, ''), worktree_branch_compacted_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 `), envID) if err != nil { return nil, err } defer func() { _ = rows.Close() }() var out []*models.TaskEnvironmentRepo for rows.Next() { repo := &models.TaskEnvironmentRepo{} var compactedAt, mergedAt, deletedAt sql.NullTime if err := rows.Scan( &repo.ID, &repo.TaskEnvironmentID, &repo.RepositoryID, &repo.BranchSlug, &repo.WorktreeID, &repo.WorktreePath, &repo.WorktreeBranch, &repo.WorktreeBranchOwner, &repo.WorktreeIntegrationRef, &repo.WorktreeRecoveryHeadSHA, &compactedAt, &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 compactedAt.Valid { t := compactedAt.Time repo.WorktreeBranchCompactedAt = &t } if deletedAt.Valid { t := deletedAt.Time repo.DeletedAt = &t } out = append(out, repo) } return out, rows.Err() } // UpdateTaskEnvironmentRepo updates an existing per-repo row. func (r *Repository) UpdateTaskEnvironmentRepo(ctx context.Context, repo *models.TaskEnvironmentRepo) error { repo.UpdatedAt = time.Now().UTC() tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var environmentID string if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_environment_id FROM task_environment_repos WHERE id = ?`), repo.ID).Scan(&environmentID); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task environment repo not found: %s", repo.ID) } return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID); err != nil { return err } result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_environment_repos SET branch_slug = ?, worktree_id = ?, worktree_path = ?, worktree_branch = ?, worktree_branch_owner = ?, worktree_integration_ref = ?, worktree_recovery_head_sha = ?, worktree_branch_compacted_at = ?, position = ?, error_message = ?, status = ?, merged_at = ?, deleted_at = ?, updated_at = ? WHERE id = ? `), repo.BranchSlug, repo.WorktreeID, repo.WorktreePath, repo.WorktreeBranch, repo.WorktreeBranchOwner, repo.WorktreeIntegrationRef, repo.WorktreeRecoveryHeadSHA, repo.WorktreeBranchCompactedAt, repo.Position, repo.ErrorMessage, repo.Status, repo.MergedAt, repo.DeletedAt, repo.UpdatedAt, repo.ID, ) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("task environment repo not found: %s", repo.ID) } return tx.Commit() } // DeleteTaskEnvironmentRepo removes a single per-repo row. func (r *Repository) DeleteTaskEnvironmentRepo(ctx context.Context, id string) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var environmentID string if err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_environment_id FROM task_environment_repos WHERE id = ?`), id).Scan(&environmentID); err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task environment repo not found: %s", id) } return err } if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, environmentID); err != nil { return err } result, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_environment_repos WHERE id = ?`), id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("task environment repo not found: %s", id) } return tx.Commit() } // DeleteTaskEnvironmentReposByEnv removes all per-repo rows for an environment. func (r *Repository) DeleteTaskEnvironmentReposByEnv(ctx context.Context, envID string) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, envID); err != nil { return err } if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_environment_repos WHERE task_environment_id = ?`), envID); err != nil { return err } return tx.Commit() }