Single ordered mutability evaluator
apps/backend/internal/task/models/runner_mutability.go ↗One function implements the ten ordered conditions so projection and enforcement cannot disagree.
Evaluator
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}
}
}
Closed vocabulary
const (
RunnerReasonEligible = "eligible"
RunnerReasonEvaluationUnavailable = "evaluation_unavailable"
RunnerReasonTaskArchived = "task_archived"
RunnerReasonNoRepository = "no_repository"
RunnerReasonMultipleRepositories = "multiple_repositories"
RunnerReasonSessionExists = "session_exists"
RunnerReasonEnvironmentExists = "environment_exists"
RunnerReasonExecutorRunning = "executor_running"
RunnerReasonWorkspaceFolderAttached = "workspace_folder_attached"
RunnerReasonWorkspacePathSet = "workspace_path_set"
RunnerReasonWorkspaceGroupMember = "workspace_group_member"
RunnerReasonWorkspaceBindingNotIndependent = "workspace_binding_not_independent"
)
Service switch with compatibility gate
apps/backend/internal/task/service/service_runner_switch.go ↗The service authorizes, resolves compatibility outside the lock, then delegates to the locked repository write.
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 {
if errors.Is(err, repoerrors.ErrTaskNotFound) {
return nil, err
}
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, 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,
CompatibilityResolutionFailed: compat.resolutionFailed,
CompatibilityCloneURLFound: compat.cloneURLFound,
ResolvedRepositoryID: compat.repositoryID,
ResolvedRepositoryUpdatedAt: compat.repositoryUpdatedAt,
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 resolution
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}
}
repo, err := s.repoEntities.GetRepository(ctx, links[0].RepositoryID)
if err != nil {
return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
}
found, err := runnerRepositoryHasCloneURL(ctx, repo)
if err != nil {
return runnerCompatibilityResolution{applicable: true, resolutionFailed: true}
}
return runnerCompatibilityResolution{
applicable: true, checked: true, cloneURLFound: found,
repositoryID: links[0].RepositoryID, repositoryUpdatedAt: links[0].UpdatedAt,
}
}
Batched projection
func (s *Service) BuildRunnerMutabilityViews(ctx context.Context, tasks []*models.Task) map[string]RunnerMutabilityView {
ids := make([]string, 0, len(tasks))
for _, t := range tasks {
if t != nil { ids = append(ids, t.ID) }
}
batch, err := s.loadRunnerMutabilitySignalBatch(ctx, ids)
if err != nil {
for _, id := range ids { out[id] = RunnerMutabilityView{Reason: models.RunnerReasonEvaluationUnavailable} }
return out
}
for _, t := range tasks {
out[t.ID] = batch.verdictFor(t)
}
return out
}
Locked repository transaction
apps/backend/internal/task/repository/sqlite/runner_switch.go ↗The repository takes the task row lock, re-evaluates mutability, confirms compatibility, 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 {
if errors.Is(err, kandevdb.ErrTaskRowNotFound) {
return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, req.TaskID)
}
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
}
task, err := r.runnerSwitchReadTaskTx(ctx, tx, req.TaskID)
if err != nil { return nil, err }
verdict, repoSnapshot, err := r.runnerSwitchEvaluate(ctx, req, task)
if err != nil { return nil, err }
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 != nil { return nil, err }
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err)
}
return result, nil
}
Row lock helper
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 on every read path
apps/backend/internal/task/dto/task_runner_mutability.go ↗Every task projection stamps the derived verdict so the client never computes the rule itself.
DTO enricher
func EnrichTaskRunnerMutability(dto *TaskDTO, projection TaskRunnerMutabilityProjection) {
if dto == nil {
return
}
dto.RunnerEditable = projection.Editable
dto.RunnerIneligibleReason = projection.Reason
}
Boot payload wiring
runnerViews := b.p.taskSvc.BuildRunnerMutabilityViews(ctx, tasks)
for _, task := range tasks {
dto := taskdto.FromTaskWithSessionInfo(task, primarySessionID, sessionCount, info.reviewStatus, info.executorID, info.executorProfileID, info.executorType, info.executorName, info.agentName, info.agentProfileID, info.workingDirectory, info.sessionState, bootPendingActionPtr(info.sessionID, pendingActionsBySession))
taskdto.EnrichTaskRunnerMutability(&dto, bootRunnerMutabilityProjection(runnerViews[task.ID]))
result = append(result, dto)
}
HTTP list wiring
runnerViews := svc.BuildRunnerMutabilityViews(ctx, tasks)
for _, task := range tasks {
taskDTO := dto.FromTaskWithSessionInfo(task, primarySessionID, sessionCount, si.reviewStatus, si.executorID, si.executorProfileID, si.executorType, si.executorName, si.agentName, si.agentProfileID, si.workingDirectory, si.sessionState, dto.PendingActionPtr(si.sessionID, pendingActionsBySession))
dto.EnrichTaskRunnerMutability(&taskDTO, runnerMutabilityProjection(runnerViews[task.ID]))
result = append(result, taskDTO)
}
Frontend WS call and dialog gating
apps/web/lib/api/domains/task-runner-api.ts ↗The web client calls task.runner over WS and gates the executor picker on the projected verdict.
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;
}
Kanban mapper fail-closed
function runnerMutabilityProjection(source: TaskLike) {
return {
runnerEditable: source.runner_editable ?? false,
runnerIneligibleReason: source.runner_ineligible_reason ?? "evaluation_unavailable",
};
}
Dialog selector gating
export function CreateModeSelectors(props: CreateModeSelectorsProps) {
return (
<CreateEditSelectors
runnerEditable={props.runnerEditable}
runnerIneligibleReason={props.runnerIneligibleReason}
executorProfileOptions={props.executorProfileOptions}
executorProfileId={props.fs.executorProfileId}
onExecutorProfileChange={props.onExecutorProfileChange}
/>
);
}