PR #3559
Sections
Review

feat(tasks): allow switching a task's executor before materialization

main ← feature/runner-switch-before-f982c1 111 files +2847 −312 PR #3559 ↗

Tasks that have not materialized any runtime artifact can now switch their executor profile through a new task.runner action, with a single server-derived mutability verdict projected on every task read.

Why this change

Every task starts on the worktree executor by default. A user who wants a different runner must edit metadata by hand and delete durable state, because no product surface exists to move a task before it materializes.

What it does

Architecture, end to end

Projection and switch share one evaluator. The switch resolves compatibility outside the lock, then re-checks everything inside the lock before it writes.

flowchart LR
  Client[Web client] --> Proj[Task projection]
  Client -- task.runner --> Handler[WS handler]
  Handler --> Service[Task service]
  Service --> Compat[Compatibility gate]
  Compat -- clone URL check --> Repo[Repository]
  Service --> RepoTx[(Task repo TX)]
  RepoTx --> Lock[LockTaskRowInTx]
  Lock --> Eval[EvaluateRunnerMutability]
  Eval --> Write[Write metadata key]
  Write --> Event[task.updated event]
  Event --> Client
  Client -- next start --> Exec[Executor PrepareSession]
  Exec --> Retry[Reload on ErrTaskRunnerChanged]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func EvaluateRunnerMutability(s RunnerMutabilitySignals) RunnerMutabilityVerdict
Click for details →

One function implements the ten ordered conditions so projection and enforcement cannot disagree.

Verdict
func EvaluateRunnerMutability(s RunnerMutabilitySignals) RunnerMutabilityVerdict {
  switch {
  case s.Archived:
    return RunnerMutabilityVerdict{false, RunnerReasonTaskArchived}
  case s.RepositoryCount == 0:
    return RunnerMutabilityVerdict{false, RunnerReasonNoRepository}
  case s.RepositoryCount > 1:
    return RunnerMutabilityVerdict{false, RunnerReasonMultipleRepositories}
  case s.HasSession:
    return RunnerMutabilityVerdict{false, RunnerReasonSessionExists}
  case s.HasEnvironment:
    return RunnerMutabilityVerdict{false, RunnerReasonEnvironmentExists}
  case s.HasExecutorRunning:
    return RunnerMutabilityVerdict{false, RunnerReasonExecutorRunning}
  case s.HasWorkspaceFolder:
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceFolderAttached}
  case strings.TrimSpace(s.WorkspacePath) != "":
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspacePathSet}
  case s.HasActiveGroupMembership:
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceGroupMember}
  case !runnerWorkspaceBindingIndependent(s.HasParent, s.WorkspaceMode):
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceBindingNotIndependent}
  default:
    return RunnerMutabilityVerdict{true, RunnerReasonEligible}
  }
}
Signals
type RunnerMutabilitySignals struct {
  Archived                 bool
  RepositoryCount          int
  HasSession               bool
  HasEnvironment           bool
  HasExecutorRunning       bool
  HasWorkspaceFolder       bool
  WorkspacePath            string
  HasActiveGroupMembership bool
  HasParent                bool
  WorkspaceMode            string
}
func (s *Service) SwitchTaskRunner(ctx context.Context, taskID, executorProfileID string) (*models.Task, error)
Click for details →

The service batches mutability reads for lists and runs the compatibility gate outside the transaction before it delegates to the repository.

Batched views
func (s *Service) BuildRunnerMutabilityViews(ctx context.Context, tasks []*models.Task) map[string]RunnerMutabilityView {
  out := make(map[string]RunnerMutabilityView, len(tasks))
  if len(tasks) == 0 {
    return out
  }
  batch, err := s.loadRunnerMutabilitySignalBatch(ctx, ids)
  if err != nil {
    s.logger.Warn("failed to load signals for runner mutability", zap.Error(err))
    return unavailable()
  }
  for _, t := range tasks {
    out[t.ID] = batch.verdictFor(t)
  }
  return out
}
Switch entry
func (s *Service) SwitchTaskRunner(ctx context.Context, taskID, executorProfileID string) (*models.Task, error) {
  if strings.TrimSpace(taskID) == "" || strings.TrimSpace(executorProfileID) == "" {
    return nil, ErrRunnerSwitchMalformed
  }
  task, err := s.tasks.GetTask(ctx, taskID)
  if err != nil {
    return nil, err
  }
  if err := s.authorizeTaskScope(ctx, taskID, authz.ScopeTaskWrite); err != nil {
    return nil, err
  }
  executor, err := s.resolveExecutorForProfile(ctx, executorProfileID)
  if err != nil {
    return nil, err
  }
  compat := s.resolveRunnerCompatibility(ctx, taskID, executor)
  req := models.RunnerSwitchRequest{
    TaskID: taskID, ExecutorProfileID: executorProfileID,
    CompatibilityApplicable: compat.applicable,
    CompatibilityChecked: compat.checked,
    CompatibilityCloneURLFound: compat.cloneURLFound,
    ResolvedRepositoryID: compat.repositoryID,
    GroupMembershipChecker: s.runnerGroupMembershipChecker,
  }
  result, err := s.tasks.SwitchTaskRunner(ctx, req)
  if err != nil {
    return nil, err
  }
  if result.Changed {
    s.PublishTaskUpdated(ctx, result.Task)
  }
  return result.Task, nil
}
Compatibility gate
func (s *Service) resolveRunnerCompatibility(ctx context.Context, taskID string, executor *models.Executor) runnerCompatibilityResolution {
  if s.executorCapabilityProber == nil || !s.executorCapabilityProber.RequiresCloneURL(string(executor.Type)) {
    return runnerCompatibilityResolution{}
  }
  links, err := s.taskRepos.ListTaskRepositories(ctx, taskID)
  if err != nil {
    return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
  }
  if len(links) != 1 {
    return runnerCompatibilityResolution{applicable: true}
  }
  found, err := runnerRepositoryHasCloneURL(ctx, repo)
  if err != nil {
    return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
  }
  return runnerCompatibilityResolution{applicable: true, checked: true, cloneURLFound: found, repositoryID: link.RepositoryID}
}
func (r *Repository) SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error)
Click for details →

The repository takes the task row lock, re-evaluates mutability, confirms the compatibility snapshot, and writes one metadata key atomically.

Transaction
func (r *Repository) SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) {
  tx, err := r.db.BeginTxx(ctx, nil)
  if err != nil {
    return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
  }
  defer func() { _ = tx.Rollback() }()
  if err := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), req.TaskID); err != nil {
    return nil, err
  }
  task, err := r.runnerSwitchReadTaskTx(ctx, tx, req.TaskID)
  verdict, repoSnapshot, err := r.runnerSwitchEvaluate(ctx, req, task)
  if !verdict.Editable {
    return nil, &repoerrors.ErrRunnerMutabilityConflict{Reason: verdict.Reason}
  }
  switch {
  case req.CompatibilityResolutionFailed:
    return nil, fmt.Errorf("%w: compatibility resolution failed", repoerrors.ErrRunnerEvaluationUnavailable)
  case req.CompatibilityChecked:
    if err := runnerSwitchConfirmCompatibility(req, repoSnapshot); err != nil {
      return nil, err
    }
  case req.CompatibilityApplicable:
    return nil, fmt.Errorf("%w: repository shape changed", repoerrors.ErrRunnerEvaluationUnavailable)
  }
  result, err := r.runnerSwitchApply(ctx, tx, task, req.ExecutorProfileID)
  if err := tx.Commit(); err != nil {
    return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
  }
  return result, nil
}
Row lock
func LockTaskRowInTx(ctx context.Context, tx *sqlx.Tx, driverName, taskID string) error {
  if driverName != pgxDriverName {
    return nil
  }
  var locked string
  if err := tx.QueryRowContext(ctx, tx.Rebind(`SELECT id FROM tasks WHERE id = ? FOR UPDATE`), taskID).Scan(&locked); err != nil {
    if errors.Is(err, sql.ErrNoRows) {
      return fmt.Errorf("%w: %s", ErrTaskRowNotFound, taskID)
    }
    return fmt.Errorf("lock task row: %w", err)
  }
  return nil
}
func EnrichTaskRunnerMutability(dto *TaskDTO, projection TaskRunnerMutabilityProjection)
Click for details →

Every task read path stamps the derived verdict onto the DTO so the client never computes it.

DTO enrichment
func EnrichTaskRunnerMutability(dto *TaskDTO, projection TaskRunnerMutabilityProjection) {
  if dto == nil {
    return
  }
  dto.RunnerEditable = projection.Editable
  dto.RunnerIneligibleReason = projection.Reason
}
Boot payload
runnerViews := b.p.taskSvc.BuildRunnerMutabilityViews(ctx, tasks)
// ...
  taskdto.EnrichTaskRunnerMutability(&dto, bootRunnerMutabilityProjection(runnerViews[task.ID]))
  taskdto.EnrichTaskStatusSummary(&dto, task.ID, statusSummaries)
WS handler
func (h *TaskHandlers) wsUpdateTaskRunner(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
  var req wsUpdateTaskRunnerRequest
  if err := msg.ParsePayload(&req); err != nil {
    return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
  }
  task, err := h.service.SwitchTaskRunner(ctx, req.ID, req.ExecutorProfileID)
  if err != nil {
    return runnerSwitchWSError(msg, err, h.logger)
  }
  dtos, err := buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, h.taskParkedProjection, []*models.Task{task})
  return ws.NewResponse(msg.ID, msg.Action, dtos[0])
}
Frontend: store and dialogapps/web/lib/kanban/map-task.ts ↗
function runnerMutabilityProjection(source: TaskLike)
Click for details →

The kanban mapper fails closed on missing fields and the edit dialog tracks whether the user touched the runner selector.

Kanban mapper
function runnerMutabilityProjection(source: TaskLike) {
  return {
    runnerEditable: source.runner_editable ?? false,
    runnerIneligibleReason: source.runner_ineligible_reason ?? "evaluation_unavailable",
  };
}
Edit target
export type SidebarTaskEditTarget = {
  id: string;
  title: string;
  workflowId: string;
  workflowStepId: string;
  primaryExecutorProfileId?: string;
  runnerEditable?: boolean;
  runnerIneligibleReason?: string;
};
WS API
export async function switchTaskRunner(taskId: string, executorProfileId: string): Promise<Task> {
  const client = getWebSocketClient();
  if (!client) throw new Error(WS_CLIENT_UNAVAILABLE);
  const response = await client.request("task.runner", {
    id: taskId,
    executor_profile_id: executorProfileId,
  });
  return response as Task;
}
func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, ...) (string, error)
Click for details →

Session preparation detects a concurrent runner switch, reloads the task, and retries once so the new runner takes effect.

Retry on runner change
func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, bindWorkspace bool, taskEnvironmentID string, workflowRoute *models.WorkflowSessionRoute) (string, error) {
  runnerProfileExplicit := taskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "")
  currentTask := task
  currentExecutorProfileID := executorProfileID
  for attempt := 0; attempt < 2; attempt++ {
    sessionID, err := e.prepareSessionAttempt(ctx, currentTask, agentProfileID, executorID, currentExecutorProfileID, workflowStepID, bindWorkspace, taskEnvironmentID, workflowRoute)
    if err == nil || !errors.Is(err, models.ErrTaskRunnerChanged) || runnerProfileExplicit || attempt == 1 {
      return sessionID, err
    }
    refreshed, refreshedExecutorProfileID, refreshErr := e.reloadTaskForRunnerRetry(ctx, task.ID)
    currentTask = refreshed
    currentExecutorProfileID = refreshedExecutorProfileID
  }
  return "", models.ErrTaskRunnerChanged
}
Context flag
func WithTaskRunnerProfileExplicit(ctx context.Context, explicit bool) context.Context {
  return context.WithValue(ctx, taskRunnerProfileExplicitKey{}, explicit)
}
func taskRunnerResolution(task *v1.Task, executorProfileID string, explicit bool) (bool, string) {
  resolvedProfileID := taskRunnerProfileID(task)
  return !explicit && executorProfileID != "" && executorProfileID == resolvedProfileID, resolvedProfileID
}
Read the changes as a list

Single mutability evaluator

apps/backend/internal/task/models/runner_mutability.go

One function implements the ten ordered conditions so projection and enforcement cannot disagree.

Verdict
func EvaluateRunnerMutability(s RunnerMutabilitySignals) RunnerMutabilityVerdict {
  switch {
  case s.Archived:
    return RunnerMutabilityVerdict{false, RunnerReasonTaskArchived}
  case s.RepositoryCount == 0:
    return RunnerMutabilityVerdict{false, RunnerReasonNoRepository}
  case s.RepositoryCount > 1:
    return RunnerMutabilityVerdict{false, RunnerReasonMultipleRepositories}
  case s.HasSession:
    return RunnerMutabilityVerdict{false, RunnerReasonSessionExists}
  case s.HasEnvironment:
    return RunnerMutabilityVerdict{false, RunnerReasonEnvironmentExists}
  case s.HasExecutorRunning:
    return RunnerMutabilityVerdict{false, RunnerReasonExecutorRunning}
  case s.HasWorkspaceFolder:
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceFolderAttached}
  case strings.TrimSpace(s.WorkspacePath) != "":
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspacePathSet}
  case s.HasActiveGroupMembership:
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceGroupMember}
  case !runnerWorkspaceBindingIndependent(s.HasParent, s.WorkspaceMode):
    return RunnerMutabilityVerdict{false, RunnerReasonWorkspaceBindingNotIndependent}
  default:
    return RunnerMutabilityVerdict{true, RunnerReasonEligible}
  }
}
Signals
type RunnerMutabilitySignals struct {
  Archived                 bool
  RepositoryCount          int
  HasSession               bool
  HasEnvironment           bool
  HasExecutorRunning       bool
  HasWorkspaceFolder       bool
  WorkspacePath            string
  HasActiveGroupMembership bool
  HasParent                bool
  WorkspaceMode            string
}

Service: batched projection and switch

apps/backend/internal/task/service/service_runner_switch.go

The service batches mutability reads for lists and runs the compatibility gate outside the transaction before it delegates to the repository.

Batched views
func (s *Service) BuildRunnerMutabilityViews(ctx context.Context, tasks []*models.Task) map[string]RunnerMutabilityView {
  out := make(map[string]RunnerMutabilityView, len(tasks))
  if len(tasks) == 0 {
    return out
  }
  batch, err := s.loadRunnerMutabilitySignalBatch(ctx, ids)
  if err != nil {
    s.logger.Warn("failed to load signals for runner mutability", zap.Error(err))
    return unavailable()
  }
  for _, t := range tasks {
    out[t.ID] = batch.verdictFor(t)
  }
  return out
}
Switch entry
func (s *Service) SwitchTaskRunner(ctx context.Context, taskID, executorProfileID string) (*models.Task, error) {
  if strings.TrimSpace(taskID) == "" || strings.TrimSpace(executorProfileID) == "" {
    return nil, ErrRunnerSwitchMalformed
  }
  task, err := s.tasks.GetTask(ctx, taskID)
  if err != nil {
    return nil, err
  }
  if err := s.authorizeTaskScope(ctx, taskID, authz.ScopeTaskWrite); err != nil {
    return nil, err
  }
  executor, err := s.resolveExecutorForProfile(ctx, executorProfileID)
  if err != nil {
    return nil, err
  }
  compat := s.resolveRunnerCompatibility(ctx, taskID, executor)
  req := models.RunnerSwitchRequest{
    TaskID: taskID, ExecutorProfileID: executorProfileID,
    CompatibilityApplicable: compat.applicable,
    CompatibilityChecked: compat.checked,
    CompatibilityCloneURLFound: compat.cloneURLFound,
    ResolvedRepositoryID: compat.repositoryID,
    GroupMembershipChecker: s.runnerGroupMembershipChecker,
  }
  result, err := s.tasks.SwitchTaskRunner(ctx, req)
  if err != nil {
    return nil, err
  }
  if result.Changed {
    s.PublishTaskUpdated(ctx, result.Task)
  }
  return result.Task, nil
}
Compatibility gate
func (s *Service) resolveRunnerCompatibility(ctx context.Context, taskID string, executor *models.Executor) runnerCompatibilityResolution {
  if s.executorCapabilityProber == nil || !s.executorCapabilityProber.RequiresCloneURL(string(executor.Type)) {
    return runnerCompatibilityResolution{}
  }
  links, err := s.taskRepos.ListTaskRepositories(ctx, taskID)
  if err != nil {
    return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
  }
  if len(links) != 1 {
    return runnerCompatibilityResolution{applicable: true}
  }
  found, err := runnerRepositoryHasCloneURL(ctx, repo)
  if err != nil {
    return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
  }
  return runnerCompatibilityResolution{applicable: true, checked: true, cloneURLFound: found, repositoryID: link.RepositoryID}
}

Repository: serialized transaction

apps/backend/internal/task/repository/sqlite/runner_switch.go

The repository takes the task row lock, re-evaluates mutability, confirms the compatibility snapshot, and writes one metadata key atomically.

Transaction
func (r *Repository) SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) {
  tx, err := r.db.BeginTxx(ctx, nil)
  if err != nil {
    return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
  }
  defer func() { _ = tx.Rollback() }()
  if err := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), req.TaskID); err != nil {
    return nil, err
  }
  task, err := r.runnerSwitchReadTaskTx(ctx, tx, req.TaskID)
  verdict, repoSnapshot, err := r.runnerSwitchEvaluate(ctx, req, task)
  if !verdict.Editable {
    return nil, &repoerrors.ErrRunnerMutabilityConflict{Reason: verdict.Reason}
  }
  switch {
  case req.CompatibilityResolutionFailed:
    return nil, fmt.Errorf("%w: compatibility resolution failed", repoerrors.ErrRunnerEvaluationUnavailable)
  case req.CompatibilityChecked:
    if err := runnerSwitchConfirmCompatibility(req, repoSnapshot); err != nil {
      return nil, err
    }
  case req.CompatibilityApplicable:
    return nil, fmt.Errorf("%w: repository shape changed", repoerrors.ErrRunnerEvaluationUnavailable)
  }
  result, err := r.runnerSwitchApply(ctx, tx, task, req.ExecutorProfileID)
  if err := tx.Commit(); err != nil {
    return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
  }
  return result, nil
}
Row lock
func LockTaskRowInTx(ctx context.Context, tx *sqlx.Tx, driverName, taskID string) error {
  if driverName != pgxDriverName {
    return nil
  }
  var locked string
  if err := tx.QueryRowContext(ctx, tx.Rebind(`SELECT id FROM tasks WHERE id = ? FOR UPDATE`), taskID).Scan(&locked); err != nil {
    if errors.Is(err, sql.ErrNoRows) {
      return fmt.Errorf("%w: %s", ErrTaskRowNotFound, taskID)
    }
    return fmt.Errorf("lock task row: %w", err)
  }
  return nil
}

Projection plumbing

apps/backend/internal/task/dto/task_runner_mutability.go

Every task read path stamps the derived verdict onto the DTO so the client never computes it.

DTO enrichment
func EnrichTaskRunnerMutability(dto *TaskDTO, projection TaskRunnerMutabilityProjection) {
  if dto == nil {
    return
  }
  dto.RunnerEditable = projection.Editable
  dto.RunnerIneligibleReason = projection.Reason
}
Boot payload
runnerViews := b.p.taskSvc.BuildRunnerMutabilityViews(ctx, tasks)
// ...
  taskdto.EnrichTaskRunnerMutability(&dto, bootRunnerMutabilityProjection(runnerViews[task.ID]))
  taskdto.EnrichTaskStatusSummary(&dto, task.ID, statusSummaries)
WS handler
func (h *TaskHandlers) wsUpdateTaskRunner(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
  var req wsUpdateTaskRunnerRequest
  if err := msg.ParsePayload(&req); err != nil {
    return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
  }
  task, err := h.service.SwitchTaskRunner(ctx, req.ID, req.ExecutorProfileID)
  if err != nil {
    return runnerSwitchWSError(msg, err, h.logger)
  }
  dtos, err := buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, h.taskParkedProjection, []*models.Task{task})
  return ws.NewResponse(msg.ID, msg.Action, dtos[0])
}

Frontend: store and dialog

apps/web/lib/kanban/map-task.ts

The kanban mapper fails closed on missing fields and the edit dialog tracks whether the user touched the runner selector.

Kanban mapper
function runnerMutabilityProjection(source: TaskLike) {
  return {
    runnerEditable: source.runner_editable ?? false,
    runnerIneligibleReason: source.runner_ineligible_reason ?? "evaluation_unavailable",
  };
}
Edit target
export type SidebarTaskEditTarget = {
  id: string;
  title: string;
  workflowId: string;
  workflowStepId: string;
  primaryExecutorProfileId?: string;
  runnerEditable?: boolean;
  runnerIneligibleReason?: string;
};
WS API
export async function switchTaskRunner(taskId: string, executorProfileId: string): Promise<Task> {
  const client = getWebSocketClient();
  if (!client) throw new Error(WS_CLIENT_UNAVAILABLE);
  const response = await client.request("task.runner", {
    id: taskId,
    executor_profile_id: executorProfileId,
  });
  return response as Task;
}

Next launch uses the new runner

apps/backend/internal/orchestrator/executor/task_runner_context.go

Session preparation detects a concurrent runner switch, reloads the task, and retries once so the new runner takes effect.

Retry on runner change
func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, bindWorkspace bool, taskEnvironmentID string, workflowRoute *models.WorkflowSessionRoute) (string, error) {
  runnerProfileExplicit := taskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "")
  currentTask := task
  currentExecutorProfileID := executorProfileID
  for attempt := 0; attempt < 2; attempt++ {
    sessionID, err := e.prepareSessionAttempt(ctx, currentTask, agentProfileID, executorID, currentExecutorProfileID, workflowStepID, bindWorkspace, taskEnvironmentID, workflowRoute)
    if err == nil || !errors.Is(err, models.ErrTaskRunnerChanged) || runnerProfileExplicit || attempt == 1 {
      return sessionID, err
    }
    refreshed, refreshedExecutorProfileID, refreshErr := e.reloadTaskForRunnerRetry(ctx, task.ID)
    currentTask = refreshed
    currentExecutorProfileID = refreshedExecutorProfileID
  }
  return "", models.ErrTaskRunnerChanged
}
Context flag
func WithTaskRunnerProfileExplicit(ctx context.Context, explicit bool) context.Context {
  return context.WithValue(ctx, taskRunnerProfileExplicitKey{}, explicit)
}
func taskRunnerResolution(task *v1.Task, executorProfileID string, explicit bool) (bool, string) {
  resolvedProfileID := taskRunnerProfileID(task)
  return !explicit && executorProfileID != "" && executorProfileID == resolvedProfileID, resolvedProfileID
}

Data and storage

The verdict is derived per read and never stored. The switch writes one metadata key inside the locked transaction.

FieldTypeNotes
runner_editablebooleantrue only when no mutability condition holds
runner_ineligible_reasonstringclosed vocabulary: eligible, evaluation_unavailable, plus ten condition codes
metadata.executor_profile_idstringsingle key written by the switch; read by next session preparation
task_repositoriesrowscount drives no_repository / multiple_repositories; identity drives compatibility check
task_sessions / task_environments / executors_runningexistsany row makes the task immutable
workspace_group_membershipexistsoffice-owned; checked via GroupMembershipChecker inside the lock

Risk

6 / 10 Medium
1 low5 medium10 high

Why this score

  • Touches the task row lock and four previously unlocked writers, so a missed lock site can still race the gate.
  • Adds a new WebSocket action with a multi-stage error vocabulary that the client must map correctly.
  • No schema change and the write is one metadata key, so rollback is a code revert with no migration.

Trade-offs and review notes

Where to look first

  1. Verify EvaluateRunnerMutability order matches the spec and that every projection path calls BuildRunnerMutabilityViews.
  2. Check LockTaskRowInTx is taken by all four class-2 writers and that the switch uses the raw lock, not the cleanup barrier.
  3. Confirm runnerSwitchConfirmCompatibility rejects a stale repository snapshot as evaluation_unavailable, not as a mutability conflict.
  4. Review the frontend fail-closed merge in map-task.ts and the save ordering that runs the runner switch before other field updates.