diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 3c3676022a..1a6a9c9c31 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -776,6 +776,10 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []* // Dependency state is derived per read (never stored, so the auto-start gate // can never read a stale value). One batched call for the whole boot payload. dependencyViews := b.p.taskSvc.BuildDependencyViews(ctx, tasks) + // The boot payload is a board-snapshot projection path, so it must run the + // runner-mutability evaluation itself rather than rely on + // FromTaskWithSessionInfo's fail-closed default. + runnerViews := b.p.taskSvc.BuildRunnerMutabilityViews(ctx, tasks) result := make([]taskdto.TaskDTO, 0, len(tasks)) for _, task := range tasks { if task == nil { @@ -821,6 +825,7 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []* taskdto.EnrichTaskParkedProjection(&dto, b.p.orchestratorSvc) } taskdto.EnrichTaskDependencies(&dto, bootDependencyProjection(dependencyViews[task.ID]), task) + taskdto.EnrichTaskRunnerMutability(&dto, bootRunnerMutabilityProjection(runnerViews[task.ID])) taskdto.EnrichTaskStatusSummary(&dto, task.ID, statusSummaries) if dto.StatusSummary != nil { switch { diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index c089183339..b85264a536 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -816,6 +816,10 @@ func mapKanbanTaskState(task taskdto.TaskDTO) map[string]any { "parkedOnBackgroundWork": task.ParkedOnBackgroundWork, "parkedRevision": task.ParkedRevision, "parkedEpoch": task.ParkedEpoch, + // Runner-mutability projection: this is a camelCase whitelist, so an + // evaluated verdict is invisible on first paint until it is listed here. + "runnerEditable": task.RunnerEditable, + "runnerIneligibleReason": task.RunnerIneligibleReason, } } diff --git a/apps/backend/internal/backendapp/boot_state_routes_test.go b/apps/backend/internal/backendapp/boot_state_routes_test.go index 338767a227..4d35bd45f3 100644 --- a/apps/backend/internal/backendapp/boot_state_routes_test.go +++ b/apps/backend/internal/backendapp/boot_state_routes_test.go @@ -140,6 +140,28 @@ func TestMapKanbanTaskStateIncludesPriority(t *testing.T) { } } +func TestMapKanbanTaskStateIncludesRunnerMutability(t *testing.T) { + editable := mapKanbanTaskState(taskdto.TaskDTO{ + ID: "task-eligible", + WorkflowStepID: "step-review", + RunnerEditable: true, + RunnerIneligibleReason: "eligible", + }) + if editable["runnerEditable"] != true || editable["runnerIneligibleReason"] != "eligible" { + t.Fatalf("kanban task runner fields = %#v, want editable/eligible", editable) + } + + ineligible := mapKanbanTaskState(taskdto.TaskDTO{ + ID: "task-ineligible", + WorkflowStepID: "step-review", + RunnerEditable: false, + RunnerIneligibleReason: "session_exists", + }) + if ineligible["runnerEditable"] != false || ineligible["runnerIneligibleReason"] != "session_exists" { + t.Fatalf("kanban task runner fields = %#v, want non-editable/session_exists", ineligible) + } +} + func TestMapUserSettingsStateIncludesAzureDevOpsBrowsePreferences(t *testing.T) { preferences := json.RawMessage(`{"workspace-1":{"mode":"board","filters":{"projectId":"project-2"},"board":{"teamId":"team-2","boardId":"board-2","focusedColumnId":"done"}}}`) state := mapUserSettingsState(userdto.UserSettingsResponse{ diff --git a/apps/backend/internal/backendapp/boot_state_runner_mutability.go b/apps/backend/internal/backendapp/boot_state_runner_mutability.go new file mode 100644 index 0000000000..e7fff75a14 --- /dev/null +++ b/apps/backend/internal/backendapp/boot_state_runner_mutability.go @@ -0,0 +1,17 @@ +package backendapp + +import ( + taskdto "github.com/kandev/kandev/internal/task/dto" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// bootRunnerMutabilityProjection adapts the task service's derived +// RunnerMutabilityView to the DTO projection type. Mirrors the same adapter +// in the task handlers and bootDependencyProjection above; both exist because +// dto must stay importable from service without a cycle. +func bootRunnerMutabilityProjection(view taskservice.RunnerMutabilityView) taskdto.TaskRunnerMutabilityProjection { + return taskdto.TaskRunnerMutabilityProjection{ + Editable: view.Editable, + Reason: view.Reason, + } +} diff --git a/apps/backend/internal/backendapp/boot_state_runner_mutability_test.go b/apps/backend/internal/backendapp/boot_state_runner_mutability_test.go new file mode 100644 index 0000000000..c8e73d47b0 --- /dev/null +++ b/apps/backend/internal/backendapp/boot_state_runner_mutability_test.go @@ -0,0 +1,102 @@ +package backendapp + +import ( + "os/exec" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// initLocalGitFixture creates a real git repo so CreateRepository's local-path +// validation (which requires a `.git` directory) accepts the fixture. +func initLocalGitFixture(t *testing.T, path string) string { + t.Helper() + if err := exec.Command("git", "init", "-q", path).Run(); err != nil { + t.Skipf("git unavailable: %v", err) + } + return path +} + +// TestBootTaskDTOsWithSessionInfoEvaluatesRunnerMutability guards against the +// boot-payload projection path (one of the four REQ-TASKS-RUNNER-SWITCH-001.5 +// paths that must run the evaluation) silently falling back to FromTask's +// fail-closed default instead of the task's real verdict. +func TestBootTaskDTOsWithSessionInfoEvaluatesRunnerMutability(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := t.Context() + workspaces, err := harness.taskSvc.ListWorkspaces(ctx) + if err != nil || len(workspaces) == 0 { + t.Fatalf("ListWorkspaces: %v", err) + } + workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) + if err != nil || len(workflows) == 0 { + t.Fatalf("ListWorkflows: %v", err) + } + steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) + if err != nil || len(steps) == 0 { + t.Fatalf("ListStepsByWorkflow: %v", err) + } + repository, err := harness.taskSvc.CreateRepository(ctx, &taskservice.CreateRepositoryRequest{ + WorkspaceID: workspaces[0].ID, Name: "Boot runner repo", SourceType: "local", + LocalPath: initLocalGitFixture(t, t.TempDir()), DefaultBranch: "main", + }) + if err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + eligibleResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, + Title: "Boot eligible task", + Repositories: []taskservice.TaskRepositoryInput{{RepositoryID: repository.ID, BaseBranch: "main"}}, + }) + if err != nil { + t.Fatalf("CreateTask (eligible): %v", err) + } + + ineligibleResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, + Title: "Boot ineligible task", + Repositories: []taskservice.TaskRepositoryInput{{RepositoryID: repository.ID, BaseBranch: "main"}}, + }) + if err != nil { + t.Fatalf("CreateTask (ineligible): %v", err) + } + now := time.Now().UTC() + if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "boot-runner-session", TaskID: ineligibleResult.Task.ID, + State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + builder := bootStateBuilder{p: routeParams{taskSvc: harness.taskSvc}} + dtos := builder.taskDTOsWithSessionInfo(ctx, []*models.Task{eligibleResult.Task, ineligibleResult.Task}) + if len(dtos) != 2 { + t.Fatalf("taskDTOsWithSessionInfo returned %d DTOs, want 2", len(dtos)) + } + + byID := map[string]int{} + for i, dto := range dtos { + byID[dto.ID] = i + } + eligible := dtos[byID[eligibleResult.Task.ID]] + if !eligible.RunnerEditable || eligible.RunnerIneligibleReason != "eligible" { + t.Fatalf("eligible task runner projection = editable=%v reason=%q, want editable=true reason=eligible", + eligible.RunnerEditable, eligible.RunnerIneligibleReason) + } + + ineligible := dtos[byID[ineligibleResult.Task.ID]] + if ineligible.RunnerEditable || ineligible.RunnerIneligibleReason != "session_exists" { + t.Fatalf("ineligible task runner projection = editable=%v reason=%q, want editable=false reason=session_exists", + ineligible.RunnerEditable, ineligible.RunnerIneligibleReason) + } + + // Confirms the whole boot-map pipeline, not just the DTO field, so a + // future regression in mapKanbanTaskState's whitelist would fail here too. + mapped := mapKanbanTaskState(eligible) + if mapped["runnerEditable"] != true || mapped["runnerIneligibleReason"] != "eligible" { + t.Fatalf("mapKanbanTaskState(eligible) = %#v, want runnerEditable=true reason=eligible", mapped) + } +} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 8c464337ad..859ad57c12 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -779,6 +779,7 @@ func registerRoutes(p routeParams) { handoffSvc := taskservice.NewHandoffService(p.taskRepo, p.taskRepo, handoffDocSvc, p.officeRepo, p.officeRepo, p.log) p.taskSvc.SetWorkspacePolicyAttacher(handoffSvc) + p.taskSvc.SetWorkspaceGroupMembershipReader(p.officeRepo) handoffSvc.SetCommentReader(&officeCommentReaderAdapter{reader: p.officeRepo}) // Phase 6 wirings — materializer hook + disk cleaner. The // SessionWorktreeReader and WorkspaceCleaner interfaces are both diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index ef3a96aa9c..cfc298b0cc 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -2027,6 +2027,7 @@ func newBootStateTestHarness(t *testing.T) bootStateTestHarness { TaskEnvironments: taskRepo, Reviews: taskRepo, StatusSummaries: taskRepo, + WorkspaceFolders: taskRepo, }, eventBus, log, diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index fad3f2721e..572affcbf8 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -624,6 +624,7 @@ func startAgentInfrastructure( services.Task.SetWorkspaceSourceProviderRefresher(newTaskMCPProviderRefresher(repos.Task, lifecycleMgr, log)) services.Task.SetAgentBaseBranchPusher(lifecycleMgr) services.Task.SetAgentComparisonTargetPusher(lifecycleMgr) + services.Task.SetExecutorCapabilityProber(lifecycleMgr) lifecycleMgr.SetWorkspaceInfoProvider(services.Task) // Session/environment-scoped HTTP surfaces (shell, files, ports, vscode, diff --git a/apps/backend/internal/db/tasklock.go b/apps/backend/internal/db/tasklock.go new file mode 100644 index 0000000000..7a41964543 --- /dev/null +++ b/apps/backend/internal/db/tasklock.go @@ -0,0 +1,51 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// pgxDriverName mirrors dialect.PGX. Inlined rather than importing +// internal/db/dialect: that package's own test binary imports internal/db, +// so internal/db importing dialect back would create a build cycle for +// dialect's tests. +const pgxDriverName = "pgx" + +// ErrTaskRowNotFound is returned by LockTaskRowInTx when the named task row +// does not exist. +var ErrTaskRowNotFound = errors.New("task row not found") + +// LockTaskRowInTx takes the shared row lock on tasks(id) that serializes any +// writer able to affect a task-scoped gate condition (session, environment, +// running-executor, workspace-folder, workspace-group-membership, and the +// task row's own fields) against every other such writer and against the +// task row's own concurrent readers of that state. +// +// On PostgreSQL this is a real `SELECT ... FOR UPDATE`, held for the +// lifetime of tx. On SQLite it is a deliberate no-op: the writer pool +// (OpenSQLite) is capped at one connection, so a single open write +// transaction already excludes every other write against the same +// database for its whole lifetime — there is no second writer connection +// that could interleave. This mirrors the two-primitive split already used +// by the task-cleanup barrier (task/repository/sqlite/task_cleanup_barrier.go): +// a caller across a package boundary (this function is shared by both the +// task and office repositories, which write to the same tasks table using +// the same underlying writer handle) gets the identical guarantee without +// duplicating the dialect branch at every call site. +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 +} diff --git a/apps/backend/internal/db/tasklock_test.go b/apps/backend/internal/db/tasklock_test.go new file mode 100644 index 0000000000..79044c6eb6 --- /dev/null +++ b/apps/backend/internal/db/tasklock_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" +) + +func newTaskLockTestDB(t *testing.T) *sqlx.DB { + t.Helper() + conn, err := OpenSQLite(filepath.Join(t.TempDir(), "tasklock.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, updated_at TIMESTAMP)`); err != nil { + t.Fatalf("create tasks table: %v", err) + } + return sqlxDB +} + +func TestLockTaskRowInTx_SQLiteNoOpSucceedsRegardlessOfExistence(t *testing.T) { + sqlxDB := newTaskLockTestDB(t) + ctx := context.Background() + tx, err := sqlxDB.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + // SQLite branch is a deliberate no-op: it must not fail even for a task + // row that does not exist, because the guarantee it provides on SQLite + // comes from the single-connection writer pool, not from this query. + if err := LockTaskRowInTx(ctx, tx, "sqlite3", "does-not-exist"); err != nil { + t.Fatalf("LockTaskRowInTx (sqlite no-op) = %v, want nil", err) + } +} + +func TestLockTaskRowInTx_SQLiteSerializesViaSingleWriterConnection(t *testing.T) { + sqlxDB := newTaskLockTestDB(t) + ctx := context.Background() + if _, err := sqlxDB.Exec(`INSERT INTO tasks (id, updated_at) VALUES ('task-1', ?)`, time.Now().UTC()); err != nil { + t.Fatalf("seed task: %v", err) + } + + tx1, err := sqlxDB.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx1: %v", err) + } + if err := LockTaskRowInTx(ctx, tx1, "sqlite3", "task-1"); err != nil { + t.Fatalf("LockTaskRowInTx tx1: %v", err) + } + + // A second writer attempt against the same (MaxOpenConns=1) handle must + // block until tx1 releases the single connection — proving the + // serialization the design doc attributes to the writer pool rather + // than to this function's SQLite branch. + done := make(chan error, 1) + go func() { + _, execErr := sqlxDB.ExecContext(ctx, `UPDATE tasks SET updated_at = ? WHERE id = 'task-1'`, time.Now().UTC()) + done <- execErr + }() + + select { + case <-done: + t.Fatal("second writer completed before the first transaction committed") + case <-time.After(100 * time.Millisecond): + // Expected: still blocked on the single connection. + } + + if err := tx1.Commit(); err != nil { + t.Fatalf("commit tx1: %v", err) + } + + select { + case execErr := <-done: + if execErr != nil { + t.Fatalf("second writer error = %v, want nil", execErr) + } + case <-time.After(2 * time.Second): + t.Fatal("second writer never completed after tx1 committed") + } +} + +func TestLockTaskRowInTx_PostgresBranchQueriesForUpdate(t *testing.T) { + // The Postgres branch is exercised end-to-end by the environment-gated + // Postgres suites in task/repository/sqlite (e.g. + // TestTaskCleanupBarrierPostgres_*), which share this helper via + // lockTaskRowInTx. This unit test only pins that a non-Postgres driver + // name takes the no-op path and a Postgres driver name does not, + // without requiring a live Postgres connection. + sqlxDB := newTaskLockTestDB(t) + ctx := context.Background() + tx, err := sqlxDB.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + // Against a SQLite connection there is no "tasks" table shaped for a + // pgx driver name to match query syntax, so asserting the pgx branch + // is attempted (and fails, since this isn't really Postgres) is enough + // to prove the dialect branch is live rather than silently skipped. + err = LockTaskRowInTx(ctx, tx, "pgx", "task-1") + if err == nil { + t.Fatal("LockTaskRowInTx with pgx driver name against a non-Postgres connection = nil error, want a query error (FOR UPDATE is not valid SQLite syntax)") + } + if errors.Is(err, ErrTaskRowNotFound) { + t.Fatalf("error = %v, want a syntax/execution error, not ErrTaskRowNotFound", err) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/workspace_group_runner_switch_test.go b/apps/backend/internal/office/repository/sqlite/workspace_group_runner_switch_test.go new file mode 100644 index 0000000000..1f1b0029fd --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/workspace_group_runner_switch_test.go @@ -0,0 +1,145 @@ +package sqlite_test + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + officemodels "github.com/kandev/kandev/internal/office/models" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskrepoerrors "github.com/kandev/kandev/internal/task/repository/repoerrors" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// newRunnerSwitchGroupTestRepos opens one shared writer/reader pair — the +// same production shape internal/backendapp/storage.go wires (one *sqlx.DB +// pool feeding both the task and office repositories) — and builds both +// repositories against it. This is required, not incidental: a runner +// switch and a workspace-group membership insert race for real only when +// both go through the same SQLite writer pool, exactly as they do in +// production. +func newRunnerSwitchGroupTestRepos(t *testing.T) (*tasksqlite.Repository, *officesqlite.Repository) { + t.Helper() + path := filepath.Join(t.TempDir(), "runner-switch-group.db") + writerConn, err := dbutil.OpenSQLite(path) + if err != nil { + t.Fatalf("open sqlite writer: %v", err) + } + writer := sqlx.NewDb(writerConn, "sqlite3") + t.Cleanup(func() { _ = writer.Close() }) + + readerConn, err := dbutil.OpenSQLiteReader(path) + if err != nil { + t.Fatalf("open sqlite reader: %v", err) + } + reader := sqlx.NewDb(readerConn, "sqlite3") + t.Cleanup(func() { _ = reader.Close() }) + + taskRepo, err := tasksqlite.NewWithDB(writer, reader, nil) + if err != nil { + t.Fatalf("new task repo: %v", err) + } + officeRepo, err := officesqlite.NewWithDB(writer, reader, nil) + if err != nil { + t.Fatalf("new office repo: %v", err) + } + return taskRepo, officeRepo +} + +// TestAddWorkspaceGroupMemberRacingSwitchTaskRunnerNeverBlends is the +// AC-TASKS-RUNNER-SWITCH-002.3a/3b acceptance evidence for the +// AddWorkspaceGroupMember retrofit: a runner switch and a workspace-group +// membership insert racing on the same task must resolve to exactly one of +// two outcomes — the switch commits first and the membership insert lands +// after (a state the switch never saw), or the membership lands first and +// the switch is rejected as workspace_group_member — never a switch that +// commits alongside a membership it should have seen and blocked on. +func TestAddWorkspaceGroupMemberRacingSwitchTaskRunnerNeverBlends(t *testing.T) { + taskRepo, officeRepo := newRunnerSwitchGroupTestRepos(t) + ctx := context.Background() + + if err := taskRepo.CreateWorkspace(ctx, &taskmodels.Workspace{ID: "ws-1", Name: "ws-1"}); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := taskRepo.CreateTask(ctx, &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1", Title: "runner switch group race"}); err != nil { + t.Fatalf("create task: %v", err) + } + repository := &taskmodels.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "repo-1"} + if err := taskRepo.CreateRepository(ctx, repository); err != nil { + t.Fatalf("create repository: %v", err) + } + taskRepository := &taskmodels.TaskRepository{ID: "task-repo-1", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main"} + if err := taskRepo.CreateTaskRepository(ctx, taskRepository); err != nil { + t.Fatalf("create task repository: %v", err) + } + stored, err := taskRepo.GetTaskRepository(ctx, taskRepository.ID) + if err != nil { + t.Fatalf("reload task repository: %v", err) + } + + group := &officemodels.WorkspaceGroup{WorkspaceID: "ws-1", OwnerTaskID: "owner-task", MaterializedKind: officemodels.WorkspaceGroupKindSingleRepo} + if err := officeRepo.CreateWorkspaceGroup(ctx, group); err != nil { + t.Fatalf("create workspace group: %v", err) + } + + req := taskmodels.RunnerSwitchRequest{ + TaskID: "task-1", + ExecutorProfileID: "profile-new", + CompatibilityChecked: true, + CompatibilityCloneURLFound: true, + ResolvedRepositoryID: stored.RepositoryID, + ResolvedRepositoryUpdatedAt: stored.UpdatedAt, + GroupMembershipChecker: func(ctx context.Context, taskID string) (bool, error) { + g, err := officeRepo.GetWorkspaceGroupForTask(ctx, taskID) + return g != nil, err + }, + } + + var wg sync.WaitGroup + var switchErr, memberErr error + wg.Add(2) + go func() { + defer wg.Done() + _, switchErr = taskRepo.SwitchTaskRunner(ctx, req) + }() + go func() { + defer wg.Done() + memberErr = officeRepo.AddWorkspaceGroupMember(ctx, group.ID, "task-1", "") + }() + wg.Wait() + + if memberErr != nil { + t.Fatalf("AddWorkspaceGroupMember error = %v, want nil", memberErr) + } + + switchRejectedAsGroupMember := false + if switchErr != nil { + var conflict *taskrepoerrors.ErrRunnerMutabilityConflict + if !errors.As(switchErr, &conflict) || conflict.Reason != taskmodels.RunnerReasonWorkspaceGroupMember { + t.Fatalf("switch error = %v, want nil or ErrRunnerMutabilityConflict{workspace_group_member}", switchErr) + } + switchRejectedAsGroupMember = true + } + + task, err := taskRepo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + storedProfile, _ := task.Metadata[taskmodels.MetaKeyExecutorProfileID].(string) + + if switchRejectedAsGroupMember { + if storedProfile == "profile-new" { + t.Fatalf("switch was rejected but stored profile = %q, want unchanged", storedProfile) + } + return + } + if storedProfile != "profile-new" { + t.Fatalf("switch committed but stored profile = %q, want profile-new", storedProfile) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/workspace_groups.go b/apps/backend/internal/office/repository/sqlite/workspace_groups.go index cd877918f0..ee2acf9606 100644 --- a/apps/backend/internal/office/repository/sqlite/workspace_groups.go +++ b/apps/backend/internal/office/repository/sqlite/workspace_groups.go @@ -5,13 +5,42 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "github.com/google/uuid" + kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/office/models" ) +// workspaceGroupMaxHostParams bounds a single IN-clause query's bind +// parameters, well below SQLite's 999/32766 and PostgreSQL's 65535 limits, +// so a batched read stays portable across builds. Mirrors +// internal/task/repository/sqlite's sqliteMaxHostParams; kept local here +// because these helpers are unexported and this package cannot import them. +const workspaceGroupMaxHostParams = 500 + +// chunkTaskIDs splits ids into sub-slices of at most workspaceGroupMaxHostParams +// entries, so callers can keep IN-clause queries below the host-parameter limit. +func chunkTaskIDs(ids []string) [][]string { + if len(ids) == 0 { + return nil + } + if len(ids) <= workspaceGroupMaxHostParams { + return [][]string{ids} + } + chunks := make([][]string, 0, (len(ids)+workspaceGroupMaxHostParams-1)/workspaceGroupMaxHostParams) + for i := 0; i < len(ids); i += workspaceGroupMaxHostParams { + end := i + workspaceGroupMaxHostParams + if end > len(ids) { + end = len(ids) + } + chunks = append(chunks, ids[i:end]) + } + return chunks +} + // createWorkspaceGroupTables creates the task_workspace_groups and // task_workspace_group_members tables used by office task handoffs. // @@ -286,6 +315,12 @@ func (r *Repository) UpdateWorkspaceGroupRestoreStatus(ctx context.Context, id, // AddWorkspaceGroupMember inserts a membership row. role defaults to "member" // when empty. INSERT OR IGNORE: re-adding the same task is a no-op. +// +// Takes the shared task-row lock before writing: office and the task package +// share the same tasks table through the same SQLite writer pool, so this +// membership insert and a concurrent runner switch on taskID must resolve to +// exactly one of two outcomes rather than each proceeding unaware of the +// other. func (r *Repository) AddWorkspaceGroupMember(ctx context.Context, groupID, taskID, role string) error { if groupID == "" || taskID == "" { return errors.New("workspace group member: groupID and taskID required") @@ -293,7 +328,18 @@ func (r *Repository) AddWorkspaceGroupMember(ctx context.Context, groupID, taskI if role == "" { role = models.WorkspaceMemberRoleMember } - result, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if lockErr := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), taskID); lockErr != nil && + !errors.Is(lockErr, kandevdb.ErrTaskRowNotFound) { + return lockErr + } + + result, err := tx.ExecContext(ctx, r.db.Rebind(` INSERT OR IGNORE INTO task_workspace_group_members ( workspace_group_id, task_id, role, created_at ) @@ -308,7 +354,7 @@ func (r *Repository) AddWorkspaceGroupMember(ctx context.Context, groupID, taskI return err } else if rows == 0 { var exists bool - if err := r.db.QueryRowContext(ctx, r.db.Rebind(` + if err := tx.QueryRowContext(ctx, r.db.Rebind(` SELECT EXISTS ( SELECT 1 FROM task_workspace_group_members WHERE workspace_group_id = ? AND task_id = ? @@ -317,11 +363,11 @@ func (r *Repository) AddWorkspaceGroupMember(ctx context.Context, groupID, taskI return err } if exists { - return nil + return tx.Commit() } return fmt.Errorf("workspace group %s is not accepting members", groupID) } - return nil + return tx.Commit() } // ReleaseWorkspaceGroupMember stamps released_at + release_reason and @@ -424,3 +470,56 @@ func (r *Repository) GetWorkspaceGroupForTask(ctx context.Context, taskID string } return &g, nil } + +// HasWorkspaceGroupForTask reports whether a task currently holds an active +// (non-released) workspace-group membership, without exposing the group +// itself. Adapts GetWorkspaceGroupForTask to the existence-only contract +// task-tier callers (the runner-mutability evaluator) need, so they can read +// office's workspace-group membership without importing office's models. +func (r *Repository) HasWorkspaceGroupForTask(ctx context.Context, taskID string) (bool, error) { + g, err := r.GetWorkspaceGroupForTask(ctx, taskID) + if err != nil { + return false, err + } + return g != nil, nil +} + +// GetActiveWorkspaceGroupTaskIDs reports, for each of taskIDs, whether the +// task holds an active (non-released) workspace-group membership — the same +// predicate GetWorkspaceGroupForTask applies for one task, batched behind a +// single IN-clause query so a projection covering many tasks does not fan +// out into one query per task. +func (r *Repository) GetActiveWorkspaceGroupTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) { + result := make(map[string]bool, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + for _, chunk := range chunkTaskIDs(taskIDs) { + placeholders := make([]string, len(chunk)) + args := make([]interface{}, len(chunk)) + for i, id := range chunk { + placeholders[i], args[i] = "?", id + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(fmt.Sprintf( + `SELECT DISTINCT task_id FROM task_workspace_group_members WHERE released_at IS NULL AND task_id IN (%s)`, + strings.Join(placeholders, ","), + )), args...) + if err != nil { + return nil, err + } + for rows.Next() { + var taskID string + if err := rows.Scan(&taskID); err != nil { + _ = rows.Close() + return nil, err + } + result[taskID] = true + } + err = rows.Err() + _ = rows.Close() + if err != nil { + return nil, err + } + } + return result, nil +} diff --git a/apps/backend/internal/office/repository/sqlite/workspace_groups_active_task_ids_test.go b/apps/backend/internal/office/repository/sqlite/workspace_groups_active_task_ids_test.go new file mode 100644 index 0000000000..cc58c6ff5d --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/workspace_groups_active_task_ids_test.go @@ -0,0 +1,103 @@ +package sqlite_test + +import ( + "context" + "fmt" + "testing" + + "github.com/kandev/kandev/internal/office/models" +) + +func TestGetActiveWorkspaceGroupTaskIDs(t *testing.T) { + repo := newWorkspaceGroupTestRepo(t) + ctx := context.Background() + insertWGTask(t, repo, "task-active", false) + insertWGTask(t, repo, "task-released", false) + insertWGTask(t, repo, "task-none", false) + + g := &models.WorkspaceGroup{ + WorkspaceID: "ws-1", + OwnerTaskID: "task-active", + MaterializedKind: models.WorkspaceGroupKindSingleRepo, + } + if err := repo.CreateWorkspaceGroup(ctx, g); err != nil { + t.Fatalf("create workspace group: %v", err) + } + if err := repo.AddWorkspaceGroupMember(ctx, g.ID, "task-active", models.WorkspaceMemberRoleOwner); err != nil { + t.Fatalf("add active member: %v", err) + } + if err := repo.AddWorkspaceGroupMember(ctx, g.ID, "task-released", ""); err != nil { + t.Fatalf("add released member: %v", err) + } + if err := repo.ReleaseWorkspaceGroupMember(ctx, g.ID, "task-released", models.WorkspaceReleaseReasonArchived, ""); err != nil { + t.Fatalf("release member: %v", err) + } + + got, err := repo.GetActiveWorkspaceGroupTaskIDs(ctx, []string{"task-active", "task-released", "task-none"}) + if err != nil { + t.Fatalf("GetActiveWorkspaceGroupTaskIDs: %v", err) + } + if !got["task-active"] { + t.Error("active member reported false") + } + if got["task-released"] { + t.Error("released member reported true") + } + if got["task-none"] { + t.Error("task with no membership reported true") + } +} + +func TestGetActiveWorkspaceGroupTaskIDsEmptyInput(t *testing.T) { + repo := newWorkspaceGroupTestRepo(t) + got, err := repo.GetActiveWorkspaceGroupTaskIDs(context.Background(), nil) + if err != nil { + t.Fatalf("GetActiveWorkspaceGroupTaskIDs(nil): %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty map for empty input, got %#v", got) + } +} + +// TestGetActiveWorkspaceGroupTaskIDsChunksAcrossHostParamLimit proves the +// chunked read merges correctly when the caller-supplied task ID count +// exceeds the per-statement bind-parameter chunk size (500 internally): a +// boot or board load for a large workflow must not error the whole read, +// and a hit must still be found regardless of which chunk it falls into. +func TestGetActiveWorkspaceGroupTaskIDsChunksAcrossHostParamLimit(t *testing.T) { + repo := newWorkspaceGroupTestRepo(t) + ctx := context.Background() + insertWGTask(t, repo, "task-active-chunked", false) + + g := &models.WorkspaceGroup{ + WorkspaceID: "ws-1", + OwnerTaskID: "task-active-chunked", + MaterializedKind: models.WorkspaceGroupKindSingleRepo, + } + if err := repo.CreateWorkspaceGroup(ctx, g); err != nil { + t.Fatalf("create workspace group: %v", err) + } + if err := repo.AddWorkspaceGroupMember(ctx, g.ID, "task-active-chunked", models.WorkspaceMemberRoleOwner); err != nil { + t.Fatalf("add active member: %v", err) + } + + // Pad well past the internal chunk size (500) so the read spans at + // least two chunks, with the real hit placed last so it lands in the + // final chunk. + taskIDs := make([]string, 0, 1001) + for i := 0; i < 1000; i++ { + taskIDs = append(taskIDs, fmt.Sprintf("padding-task-%d", i)) + } + taskIDs = append(taskIDs, "task-active-chunked") + + got, err := repo.GetActiveWorkspaceGroupTaskIDs(ctx, taskIDs) + if err != nil { + t.Fatalf("GetActiveWorkspaceGroupTaskIDs with %d IDs: %v", len(taskIDs), err) + } + if !got["task-active-chunked"] { + t.Errorf("active member reported false across chunked read") + } + if len(got) != 1 { + t.Errorf("expected exactly one hit across chunked read, got %d: %#v", len(got), got) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 7a0996dd1c..d0f99cdff2 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -986,8 +986,45 @@ func workflowEnvironmentHasRepository(rows []*models.TaskEnvironmentRepo, reposi return false } -//nolint:cyclop,funlen // Session construction keeps its existing validation sequence in one transaction boundary. +// prepareSession retries a task-derived runner selection when the persistence +// transaction observes that a concurrent runner switch committed after the +// caller loaded the task. Explicit runner selections keep their existing +// caller-owned semantics and are not replaced by the task metadata. 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) + if refreshErr != nil { + return "", fmt.Errorf("reload task after runner change: %w", refreshErr) + } + if refreshed == nil { + return "", fmt.Errorf("reload task after runner change: task %s not found", task.ID) + } + currentTask = refreshed + currentExecutorProfileID = refreshedExecutorProfileID + } + return "", models.ErrTaskRunnerChanged +} + +//nolint:cyclop,funlen,gocognit // Session construction keeps its existing validation sequence in one transaction boundary. +func (e *Executor) prepareSessionAttempt(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, bindWorkspace bool, taskEnvironmentID string, workflowRoute *models.WorkflowSessionRoute) (string, error) { if err := e.admitWorktreeRecovery(ctx, task.ID); err != nil { return "", err } @@ -997,6 +1034,9 @@ func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfi } metadata := cloneMetadata(task.Metadata) + runnerResolvedFromTask, taskRunnerProfileID := taskRunnerResolution( + task, executorProfileID, taskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != ""), + ) initialRuntimeConfig, hasInitialRuntimeConfig := models.LoadInitialSessionRuntimeConfig(task.Metadata) delete(metadata, models.MetaKeyInitialSessionRuntimeConfig) delete(metadata, models.MetaKeyInitialSessionRuntimeConfigProfileID) @@ -1057,19 +1097,21 @@ func (e *Executor) prepareSession(ctx context.Context, task *v1.Task, agentProfi sessionID := uuid.New().String() now := time.Now().UTC() session := &models.TaskSession{ - ID: sessionID, - TaskID: task.ID, - AgentProfileID: agentProfileID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - WorkspacePath: workspacePath, - State: models.TaskSessionStateCreated, - StartedAt: now, - UpdatedAt: now, - AgentProfileSnapshot: agentProfileSnapshot, - IsPrimary: isPrimarySession, - IsPassthrough: isPassthrough, - Metadata: metadata, + ID: sessionID, + TaskID: task.ID, + AgentProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + WorkspacePath: workspacePath, + State: models.TaskSessionStateCreated, + StartedAt: now, + UpdatedAt: now, + AgentProfileSnapshot: agentProfileSnapshot, + IsPrimary: isPrimarySession, + IsPassthrough: isPassthrough, + Metadata: metadata, + TaskRunnerResolvedFromTask: runnerResolvedFromTask, + TaskRunnerProfileAtResolution: taskRunnerProfileID, } if taskEnvironmentID != "" { session.TaskEnvironmentID = taskEnvironmentID diff --git a/apps/backend/internal/orchestrator/executor/executor_office.go b/apps/backend/internal/orchestrator/executor/executor_office.go index 8bd0b3fc86..ec4384c877 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office.go +++ b/apps/backend/internal/orchestrator/executor/executor_office.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -85,28 +86,46 @@ func (e *Executor) EnsureSessionForAgentWithCreation( // maxOfficeSessionCreateAttempts bounds EnsureSessionForAgentWithCreation's // create-then-recover loop (AC-003.3): one initial attempt plus one retry -// after losing a create race and finding no live winner to reuse yet. +// after losing a create race or observing a stale task-derived runner. // Unbounded retry would spin forever if the guard and the recovery lookup // disagree in a way that never resolves. const maxOfficeSessionCreateAttempts = 2 // createOfficeSessionWithBoundedRecovery attempts to create a fresh office -// session, and on losing the race to a concurrent creator ( -// taskrepo.ErrOfficeSessionRaceConflict) re-reads the pair and reuses the -// winner instead of surfacing the conflict. A non-conflict create failure, -// or a failure while re-reading after a conflict, is returned as itself — -// never laundered into the sentinel and never as a nil session with a nil -// error. +// session. It reloads a task-derived runner once when the persistence guard +// rejects a stale snapshot, and on losing the race to a concurrent creator +// (taskrepo.ErrOfficeSessionRaceConflict) it re-reads the pair and reuses the +// winner instead of surfacing the conflict. A non-conflict create failure, or +// a failure while re-reading after a conflict, is returned as itself — never +// laundered into the sentinel and never as a nil session with a nil error. func (e *Executor) createOfficeSessionWithBoundedRecovery( ctx context.Context, task *v1.Task, agentInstanceID, agentProfileID, executorID, executorProfileID string, ) (*models.TaskSession, bool, error) { var lastErr error + runnerProfileExplicit := taskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "") for attempt := 0; attempt < maxOfficeSessionCreateAttempts; attempt++ { created, err := e.createOfficeSession(ctx, task, agentInstanceID, agentProfileID, executorID, executorProfileID) if err == nil { return created, true, nil } lastErr = err + if errors.Is(err, models.ErrTaskRunnerChanged) && !runnerProfileExplicit && attempt == 0 { + refreshed, refreshedExecutorProfileID, refreshErr := e.reloadTaskForRunnerRetry(ctx, task.ID) + if refreshErr != nil { + return nil, false, fmt.Errorf("reload task after runner change: %w", refreshErr) + } + if refreshed == nil { + return nil, false, fmt.Errorf("reload task after runner change: task %s not found", task.ID) + } + task = refreshed + executorProfileID = refreshedExecutorProfileID + if err := e.PreflightManagedGitCredentials( + ctx, task.WorkspaceID, task.ID, executorID, executorProfileID, + ); err != nil { + return nil, false, err + } + continue + } if !errors.Is(err, taskrepo.ErrOfficeSessionRaceConflict) { return nil, false, err } @@ -284,6 +303,9 @@ func (e *Executor) createOfficeSession( ctx context.Context, task *v1.Task, agentInstanceID, agentProfileID, executorID, executorProfileID string, ) (*models.TaskSession, error) { metadata := cloneMetadata(task.Metadata) + runnerResolvedFromTask, taskRunnerProfileID := taskRunnerResolution( + task, executorProfileID, taskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != ""), + ) primaryTaskRepo, err := e.repo.GetPrimaryTaskRepository(ctx, task.ID) if err != nil { @@ -305,18 +327,20 @@ func (e *Executor) createOfficeSession( sessionAgentProfileID = agentProfileID } session := &models.TaskSession{ - ID: uuid.New().String(), - TaskID: task.ID, - AgentProfileID: sessionAgentProfileID, - ExecutionProfileID: agentProfileID, - RepositoryID: repositoryID, - BaseBranch: baseBranch, - State: models.TaskSessionStateCreated, - StartedAt: now, - UpdatedAt: now, - AgentProfileSnapshot: agentProfileSnapshot, - IsPassthrough: isPassthrough, - Metadata: metadata, + ID: uuid.New().String(), + TaskID: task.ID, + AgentProfileID: sessionAgentProfileID, + ExecutionProfileID: agentProfileID, + RepositoryID: repositoryID, + BaseBranch: baseBranch, + State: models.TaskSessionStateCreated, + StartedAt: now, + UpdatedAt: now, + AgentProfileSnapshot: agentProfileSnapshot, + IsPassthrough: isPassthrough, + Metadata: metadata, + TaskRunnerResolvedFromTask: runnerResolvedFromTask, + TaskRunnerProfileAtResolution: taskRunnerProfileID, } if executorProfileID != "" { session.ExecutorProfileID = executorProfileID diff --git a/apps/backend/internal/orchestrator/executor/executor_office_test.go b/apps/backend/internal/orchestrator/executor/executor_office_test.go index 4957cec9bf..04d60640c1 100644 --- a/apps/backend/internal/orchestrator/executor/executor_office_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_office_test.go @@ -77,6 +77,54 @@ func TestEnsureSessionForAgent_CreatesWhenMissing(t *testing.T) { } } +func TestEnsureSessionForAgentRetriesTaskRunnerChangedAfterReload(t *testing.T) { + repo := newOfficeTaskSessionCreatingRepository() + taskID := "task-office-runner-retry" + task := &v1.Task{ + ID: taskID, + WorkspaceID: "ws-office-runner-retry", + Title: "Office runner retry", + Metadata: map[string]interface{}{models.MetaKeyExecutorProfileID: "profile-old"}, + } + repo.tasks[taskID] = &models.Task{ + ID: taskID, + WorkspaceID: task.WorkspaceID, + Metadata: map[string]interface{}{models.MetaKeyExecutorProfileID: "profile-old"}, + } + createAttempts := 0 + repo.createTaskSessionFunc = func(_ context.Context, session *models.TaskSession) error { + createAttempts++ + if createAttempts == 1 { + repo.mu.Lock() + repo.tasks[taskID].Metadata[models.MetaKeyExecutorProfileID] = "profile-new" + repo.mu.Unlock() + return models.ErrTaskRunnerChanged + } + repo.mu.Lock() + repo.sessions[session.ID] = session + repo.mu.Unlock() + return nil + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + ctx := WithTaskRunnerProfileExplicit(context.Background(), false) + + created, wasCreated, err := exec.EnsureSessionForAgentWithCreation( + ctx, task, "agent-office-runner-retry", "agent-profile", "executor", "profile-old", + ) + if err != nil { + t.Fatalf("EnsureSessionForAgentWithCreation: %v", err) + } + if !wasCreated { + t.Fatal("wasCreated = false, want true") + } + if createAttempts != 2 { + t.Fatalf("CreateTaskSession attempts = %d, want 2", createAttempts) + } + if created == nil || created.ExecutorProfileID != "profile-new" { + t.Fatalf("created session = %#v, want executor profile profile-new", created) + } +} + func TestEnsureSessionForAgentRejectsManagedIdentityBeforeCreatingSession(t *testing.T) { repo := newMockRepository() seedPreflightTaskRepository(repo, "task-office", "repo-1", &models.Repository{ diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index bbb1925a38..f9636158a5 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -181,6 +181,52 @@ func TestPrepareSession_Success(t *testing.T) { } } +func TestPrepareSessionRetriesTaskRunnerChangedAfterReload(t *testing.T) { + repo := newMockRepository() + taskID := "task-runner-retry" + repo.tasks[taskID] = &models.Task{ + ID: taskID, + WorkspaceID: "workspace-runner-retry", + Metadata: map[string]interface{}{models.MetaKeyExecutorProfileID: "profile-old"}, + } + createAttempts := 0 + repo.createTaskSessionFunc = func(_ context.Context, session *models.TaskSession) error { + createAttempts++ + if createAttempts == 1 { + repo.mu.Lock() + repo.tasks[taskID].Metadata[models.MetaKeyExecutorProfileID] = "profile-new" + repo.mu.Unlock() + return models.ErrTaskRunnerChanged + } + repo.mu.Lock() + repo.sessions[session.ID] = session + repo.mu.Unlock() + return nil + } + exec := newTestExecutor(t, &mockAgentManager{}, repo) + task := &v1.Task{ + ID: taskID, + WorkspaceID: "workspace-runner-retry", + Metadata: map[string]interface{}{models.MetaKeyExecutorProfileID: "profile-old"}, + } + ctx := WithTaskRunnerProfileExplicit(context.Background(), false) + + sessionID, err := exec.PrepareSession(ctx, task, "agent-profile", "executor", "profile-old", "") + if err != nil { + t.Fatalf("PrepareSession: %v", err) + } + if createAttempts != 2 { + t.Fatalf("CreateTaskSession attempts = %d, want 2", createAttempts) + } + created := repo.sessions[sessionID] + if created == nil { + t.Fatalf("session %q was not stored", sessionID) + } + if created.ExecutorProfileID != "profile-new" { + t.Fatalf("session executor profile = %q, want profile-new", created.ExecutorProfileID) + } +} + func TestPrepareSession_SharedGroupUsesTransactionalWorkspaceBinding(t *testing.T) { repo := newMockRepository() exec := newTestExecutor(t, &mockAgentManager{}, repo) diff --git a/apps/backend/internal/orchestrator/executor/task_runner_context.go b/apps/backend/internal/orchestrator/executor/task_runner_context.go new file mode 100644 index 0000000000..323fac40c0 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/task_runner_context.go @@ -0,0 +1,50 @@ +package executor + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type taskRunnerProfileExplicitKey struct{} + +// WithTaskRunnerProfileExplicit records whether the launch caller supplied the +// task runner profile. A false value means the profile was resolved from task +// metadata and must be checked again when the session transaction takes the +// task lock. +func WithTaskRunnerProfileExplicit(ctx context.Context, explicit bool) context.Context { + return context.WithValue(ctx, taskRunnerProfileExplicitKey{}, explicit) +} + +func taskRunnerProfileExplicit(ctx context.Context, fallback bool) bool { + if explicit, ok := ctx.Value(taskRunnerProfileExplicitKey{}).(bool); ok { + return explicit + } + return fallback +} + +func taskRunnerProfileID(task *v1.Task) string { + if task == nil { + return "" + } + profileID, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + return profileID +} + +func taskRunnerResolution(task *v1.Task, executorProfileID string, explicit bool) (bool, string) { + resolvedProfileID := taskRunnerProfileID(task) + return !explicit && executorProfileID != "" && executorProfileID == resolvedProfileID, resolvedProfileID +} + +func (e *Executor) reloadTaskForRunnerRetry(ctx context.Context, taskID string) (*v1.Task, string, error) { + refreshed, err := e.repo.GetTask(ctx, taskID) + if err != nil { + return nil, "", err + } + if refreshed == nil { + return nil, "", nil + } + task := refreshed.ToAPI() + return task, taskRunnerProfileID(task), nil +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 450430efbd..0ae42a3a6b 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -272,6 +272,7 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error { // When launchWorkspace is true, workspace infrastructure (agentctl) is launched asynchronously; // the frontend receives preparation progress via executor.prepare.progress WS events. func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, workflowStepID string, launchWorkspace bool) (string, error) { + ctx = executor.WithTaskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "") s.logger.Debug("preparing task session", zap.String("task_id", taskID), zap.String("agent_profile_id", agentProfileID), @@ -1243,6 +1244,7 @@ func (s *Service) promoteSelectedExplicitWorkflowSession( //nolint:cyclop,funlen,gocognit // launch path threads many orthogonal concerns (workflow-step / agent-profile / office-task / config-mode / route / system-prompt wrapping); splitting it would require shared mutable state across helpers func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID string, executorID string, executorProfileID string, priority string, prompt string, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment, opts startTaskOptions) (*executor.TaskExecution, error) { + ctx = executor.WithTaskRunnerProfileExplicit(ctx, strings.TrimSpace(executorProfileID) != "") // One GetWorkflowMeta read shared by profile resolution and prompt build. ctx = withWorkflowMetaCache(ctx) // Fail before task-state or session mutations when the selected logical diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 07a1de9278..c19fbc3583 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -260,6 +260,17 @@ type TaskDTO struct { // resolution. Read-only here; set through the create request or the picker. StartWhenUnblocked bool `json:"start_when_unblocked,omitempty"` + // RunnerEditable and RunnerIneligibleReason are the runner-mutability + // verdict (models.EvaluateRunnerMutability), derived on every read and + // never persisted. Always serialized, never omitted: a stale cached + // `true` would offer an action the server refuses, while a missing key + // is indistinguishable from false, so both fields are always present. + // Stamped by EnrichTaskRunnerMutability. + RunnerEditable bool `json:"runner_editable"` + // RunnerIneligibleReason is always a member of the closed reason + // vocabulary (models.RunnerReason*), never empty. + RunnerIneligibleReason string `json:"runner_ineligible_reason"` + // Office extensions AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` // AssigneeUserID is the human assignee, independent of the agent one. @@ -990,6 +1001,12 @@ func FromTaskWithSessionInfo( Interrupted: task.Metadata[models.MetaKeyInterruptedAt] != nil, AutoStartFailed: task.Metadata[models.MetaKeyAutoStartFailed] != nil, WorkspaceOrphaned: models.WorkspaceOrphaned(task.Metadata), + // RunnerEditable/RunnerIneligibleReason default fail-closed: a caller + // that builds a DTO through this path without running + // EnrichTaskRunnerMutability never evaluated the verdict, and the + // empty string is outside the closed reason vocabulary. + RunnerEditable: false, + RunnerIneligibleReason: models.RunnerReasonEvaluationUnavailable, // Office extensions. AssigneeAgentProfileID is a read-time // projection from workflow_step_participants (ADR 0005 Wave F); // the repo's task SELECTs hydrate it via a correlated subquery. diff --git a/apps/backend/internal/task/dto/task_runner_mutability.go b/apps/backend/internal/task/dto/task_runner_mutability.go new file mode 100644 index 0000000000..7ff0d60598 --- /dev/null +++ b/apps/backend/internal/task/dto/task_runner_mutability.go @@ -0,0 +1,25 @@ +package dto + +// TaskRunnerMutabilityProjection is the shape a caller supplies to stamp the +// runner-mutability verdict onto a DTO. It mirrors +// service.RunnerMutabilityView without importing the service package (dto +// must stay importable from it). +type TaskRunnerMutabilityProjection struct { + Editable bool + Reason string +} + +// EnrichTaskRunnerMutability stamps the derived runner-mutability verdict +// onto a task DTO, following the same read-time-enrichment pattern as +// EnrichTaskDependencies. Both fields are always set — never left at their +// zero value on the fail-closed path — because the empty string is outside +// the closed reason vocabulary and a caller that skips this call keeps +// FromTask's own fail-closed default instead of a silent "editable" zero +// value. +func EnrichTaskRunnerMutability(dto *TaskDTO, projection TaskRunnerMutabilityProjection) { + if dto == nil { + return + } + dto.RunnerEditable = projection.Editable + dto.RunnerIneligibleReason = projection.Reason +} diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 22d68e4d42..939f374bb7 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -28,6 +28,11 @@ const ( moveConflictCodePending = "task_move_pending" ) +// errorDetailKeyErrorCode is the JSON key every machine-readable error detail +// map keys its code under, so a client can branch on it without parsing the +// human-readable message. +const errorDetailKeyErrorCode = "error_code" + func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback string) { if isClientDisconnect(err) { abortClientDisconnect(c) @@ -81,16 +86,16 @@ func taskErrorBody(err error) gin.H { func taskErrorDetails(err error) map[string]interface{} { var selectionErr *service.RepositorySelectionError if errors.As(err, &selectionErr) { - return map[string]interface{}{"error_code": string(selectionErr.Code)} + return map[string]interface{}{errorDetailKeyErrorCode: string(selectionErr.Code)} } if errors.Is(err, service.ErrRepositoryBranchPolicyStale) { - return map[string]interface{}{"error_code": service.BranchPolicyStaleErrorCode} + return map[string]interface{}{errorDetailKeyErrorCode: service.BranchPolicyStaleErrorCode} } var dirtyWorktreeErr *service.TaskDeleteDirtyWorktreeError if errors.As(err, &dirtyWorktreeErr) { return map[string]interface{}{ - "error_code": service.TaskDeleteDirtyWorktreeErrorCode, - "dirty_worktrees": dirtyWorktreeErr.DirtyWorktrees, + errorDetailKeyErrorCode: service.TaskDeleteDirtyWorktreeErrorCode, + "dirty_worktrees": dirtyWorktreeErr.DirtyWorktrees, } } return nil diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 0783bbf126..08cc123f9a 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -190,6 +190,9 @@ func (m *mockRepository) SettleTaskExternalID(_ context.Context, _, _ string, _ func (m *mockRepository) ReleaseTaskExternalID(_ context.Context, _, _ string) (*models.Task, error) { return nil, nil } +func (m *mockRepository) SwitchTaskRunner(context.Context, models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) { + return nil, nil +} func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { return nil } @@ -557,6 +560,9 @@ func (m *mockRepository) ListExecutorsRunning(ctx context.Context) ([]*models.Ex func (m *mockRepository) ListExecutorsRunningByTaskID(ctx context.Context, taskID string) ([]*models.ExecutorRunning, error) { return nil, nil } +func (m *mockRepository) GetExecutorRunningExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) { + return map[string]bool{}, nil +} func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error { return nil } @@ -607,6 +613,9 @@ func (m *mockRepository) GetTaskEnvironment(ctx context.Context, id string) (*mo func (m *mockRepository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) { return nil, nil } +func (m *mockRepository) GetTaskEnvironmentExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) { + return map[string]bool{}, nil +} func (m *mockRepository) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error { return nil } diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index affc3f418d..63c1138f48 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -251,6 +251,7 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionTaskMove, h.wsMoveTask) dispatcher.RegisterFunc(ws.ActionTaskState, h.wsUpdateTaskState) dispatcher.RegisterFunc(ws.ActionTaskArchive, h.wsArchiveTask) + dispatcher.RegisterFunc(ws.ActionTaskRunner, h.wsUpdateTaskRunner) dispatcher.RegisterFunc(ws.ActionTaskSessionList, h.wsListTaskSessions) // Git snapshot handler (commits and cumulative diff are handled by agent/handlers/git_handlers.go) dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 657541189f..b27ae998d2 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -259,6 +259,9 @@ func buildTaskDTOsWithSessionInfo( // batched call for the whole list: a per-task query would add a round trip // per card to every board load. dependencyViews := svc.BuildDependencyViews(ctx, tasks) + // Runner-mutability verdict is likewise derived, never stored, and must + // not fan out per task. + runnerViews := svc.BuildRunnerMutabilityViews(ctx, tasks) result := make([]dto.TaskDTO, 0, len(tasks)) for _, task := range tasks { sessions := sessionsByTask[task.ID] @@ -293,6 +296,7 @@ func buildTaskDTOsWithSessionInfo( taskDTO.TaskPendingAction = dto.TaskPendingActionPtr(sessions, pendingActionsBySession) dto.EnrichTaskForegroundActivity(&taskDTO, sessions, activityProvider) dto.EnrichTaskDependencies(&taskDTO, dependencyProjection(dependencyViews[task.ID]), task) + dto.EnrichTaskRunnerMutability(&taskDTO, runnerMutabilityProjection(runnerViews[task.ID])) dto.EnrichTaskStatusSummary(&taskDTO, task.ID, statusSummaries) dto.EnrichTaskParkedProjection(&taskDTO, taskParkedProvider) if taskDTO.StatusSummary != nil { diff --git a/apps/backend/internal/task/handlers/task_http_handlers_external_id_test.go b/apps/backend/internal/task/handlers/task_http_handlers_external_id_test.go index 8164787a1f..7a8f1e6fe4 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_external_id_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_external_id_test.go @@ -187,6 +187,10 @@ func (r *idempotentCreateTaskRepo) ReleaseTaskExternalID(_ context.Context, work return nil, nil } +func (r *idempotentCreateTaskRepo) SwitchTaskRunner(context.Context, models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) { + return nil, nil +} + func (r *idempotentCreateTaskRepo) GetWorkflow(_ context.Context, id string) (*models.Workflow, error) { return &models.Workflow{ID: id, WorkspaceID: "ws-1"}, nil } diff --git a/apps/backend/internal/task/handlers/task_runner_handlers.go b/apps/backend/internal/task/handlers/task_runner_handlers.go new file mode 100644 index 0000000000..0df94ea558 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_runner_handlers.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/service" +) + +// runnerMutabilityProjection adapts the service's derived RunnerMutabilityView +// to the DTO package's projection type. The two are kept separate so dto +// stays importable from service without a cycle. +func runnerMutabilityProjection(view service.RunnerMutabilityView) dto.TaskRunnerMutabilityProjection { + return dto.TaskRunnerMutabilityProjection{ + Editable: view.Editable, + Reason: view.Reason, + } +} diff --git a/apps/backend/internal/task/handlers/task_runner_handlers_test.go b/apps/backend/internal/task/handlers/task_runner_handlers_test.go new file mode 100644 index 0000000000..8ea540591d --- /dev/null +++ b/apps/backend/internal/task/handlers/task_runner_handlers_test.go @@ -0,0 +1,298 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/task/service" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// runnerSwitchWSRepo is a minimal, fully-controlled fake for the task.runner +// WS handler: one task, one executor profile/executor pair, and an +// in-memory metadata map that SwitchTaskRunner mutates on a successful +// no-conflict switch, so the handler's response DTO reflects a real change. +type runnerSwitchWSRepo struct { + mockRepository + + task *models.Task + executor *models.Executor + executorProfile *models.ExecutorProfile + mutabilityBlocked bool +} + +func (r *runnerSwitchWSRepo) GetTask(_ context.Context, id string) (*models.Task, error) { + if r.task == nil || id != r.task.ID { + return nil, repoerrors.ErrTaskNotFound + } + return r.task, nil +} + +func (r *runnerSwitchWSRepo) GetExecutorProfile(_ context.Context, id string) (*models.ExecutorProfile, error) { + if r.executorProfile == nil || id != r.executorProfile.ID { + return nil, repoerrors.ErrExecutorProfileNotFound + } + return r.executorProfile, nil +} + +func (r *runnerSwitchWSRepo) GetExecutor(_ context.Context, id string) (*models.Executor, error) { + if r.executor == nil || id != r.executor.ID { + return nil, models.ErrExecutorNotFound + } + return r.executor, nil +} + +// ListTaskWorkspaceFoldersByTaskIDs and its siblings satisfy +// repository.TaskWorkspaceFolderRepository so this fake can be wired as +// service.Repos.WorkspaceFolders. Without it, BuildRunnerMutabilityViews +// degrades every verdict to evaluation_unavailable regardless of the other +// signals, which would mask a real assertion on the evaluated reason. +func (r *runnerSwitchWSRepo) ListTaskWorkspaceFolders(context.Context, string) ([]*models.TaskWorkspaceFolder, error) { + return nil, nil +} + +func (r *runnerSwitchWSRepo) ListTaskWorkspaceFoldersByTaskIDs(context.Context, []string) (map[string][]*models.TaskWorkspaceFolder, error) { + return map[string][]*models.TaskWorkspaceFolder{}, nil +} + +func (r *runnerSwitchWSRepo) CreateWorkspaceSourceBatch(context.Context, *models.WorkspaceSourceBatch) error { + return nil +} + +func (r *runnerSwitchWSRepo) CompensateWorkspaceSourceBatch(context.Context, *models.WorkspaceSourceBatch) error { + return nil +} + +func (r *runnerSwitchWSRepo) SwitchTaskRunner(_ context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) { + if r.mutabilityBlocked { + return nil, &repoerrors.ErrRunnerMutabilityConflict{Reason: models.RunnerReasonSessionExists} + } + stored, _ := r.task.Metadata[models.MetaKeyExecutorProfileID].(string) + if stored == req.ExecutorProfileID { + return &models.RunnerSwitchResult{Task: r.task, Changed: false}, nil + } + if r.task.Metadata == nil { + r.task.Metadata = map[string]interface{}{} + } + r.task.Metadata[models.MetaKeyExecutorProfileID] = req.ExecutorProfileID + r.task.UpdatedAt = time.Now().UTC() + return &models.RunnerSwitchResult{Task: r.task, Changed: true}, nil +} + +func newRunnerSwitchWSHandlers(t *testing.T, repo *runnerSwitchWSRepo) *TaskHandlers { + t.Helper() + log := newTestLogger(t) + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, WorkspaceFolders: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + return &TaskHandlers{service: svc, logger: log} +} + +func runnerSwitchWSMessage(t *testing.T, id, executorProfileID string) *ws.Message { + t.Helper() + raw, err := json.Marshal(map[string]any{"id": id, "executor_profile_id": executorProfileID}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return &ws.Message{ID: "msg-1", Action: ws.ActionTaskRunner, Payload: raw} +} + +func TestWSUpdateTaskRunnerSuccessReturnsEnrichedDTO(t *testing.T) { + repo := &runnerSwitchWSRepo{ + task: &models.Task{ID: "task-1", WorkspaceID: "ws-1", Title: "T"}, + executor: &models.Executor{ID: "exec-1", Status: models.ExecutorStatusActive}, + executorProfile: &models.ExecutorProfile{ID: "profile-1", ExecutorID: "exec-1"}, + } + h := newRunnerSwitchWSHandlers(t, repo) + + resp, err := h.wsUpdateTaskRunner(context.Background(), runnerSwitchWSMessage(t, "task-1", "profile-1")) + if err != nil { + t.Fatalf("wsUpdateTaskRunner: %v", err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %v, want response (payload: %s)", resp.Type, resp.Payload) + } + var body map[string]any + if err := json.Unmarshal(resp.Payload, &body); err != nil { + t.Fatalf("unmarshal response payload: %v", err) + } + if body["id"] != "task-1" { + t.Fatalf("response id = %v, want task-1", body["id"]) + } + if _, ok := body["runner_editable"]; !ok { + t.Fatal("response missing runner_editable") + } + if _, ok := body["runner_ineligible_reason"]; !ok { + t.Fatal("response missing runner_ineligible_reason") + } + if got, _ := repo.task.Metadata[models.MetaKeyExecutorProfileID].(string); got != "profile-1" { + t.Fatalf("persisted executor_profile_id = %q, want profile-1", got) + } +} + +func TestWSUpdateTaskRunnerInvalidPayload(t *testing.T) { + h := newRunnerSwitchWSHandlers(t, &runnerSwitchWSRepo{}) + resp, err := h.wsUpdateTaskRunner(context.Background(), &ws.Message{ID: "msg-1", Action: ws.ActionTaskRunner, Payload: json.RawMessage(`not-json`)}) + if err != nil { + t.Fatalf("wsUpdateTaskRunner: %v", err) + } + assertWSErrorCode(t, resp, ws.ErrorCodeBadRequest) +} + +func TestWSUpdateTaskRunnerErrorMapping(t *testing.T) { + tests := []struct { + name string + repo *runnerSwitchWSRepo + id string + profile string + wantCode string + }{ + { + name: "malformed", + repo: &runnerSwitchWSRepo{}, + id: "", + profile: "profile-1", + wantCode: ws.ErrorCodeValidation, + }, + { + name: "task not found", + repo: &runnerSwitchWSRepo{}, + id: "task-missing", + profile: "profile-1", + wantCode: ws.ErrorCodeNotFound, + }, + { + name: "target invalid", + repo: &runnerSwitchWSRepo{ + task: &models.Task{ID: "task-1", WorkspaceID: "ws-1", Title: "T"}, + }, + id: "task-1", + profile: "profile-missing", + wantCode: ws.ErrorCodeValidation, + }, + { + name: "mutability conflict", + repo: &runnerSwitchWSRepo{ + task: &models.Task{ID: "task-1", WorkspaceID: "ws-1", Title: "T"}, + executor: &models.Executor{ID: "exec-1", Status: models.ExecutorStatusActive}, + executorProfile: &models.ExecutorProfile{ID: "profile-1", ExecutorID: "exec-1"}, + mutabilityBlocked: true, + }, + id: "task-1", + profile: "profile-1", + wantCode: ws.ErrorCodeConflict, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := newRunnerSwitchWSHandlers(t, tc.repo) + resp, err := h.wsUpdateTaskRunner(context.Background(), runnerSwitchWSMessage(t, tc.id, tc.profile)) + if err != nil { + t.Fatalf("wsUpdateTaskRunner: %v", err) + } + assertWSErrorCode(t, resp, tc.wantCode) + }) + } +} + +// TestWSGetTaskReturnsEvaluatedRunnerMutability regression-tests that +// task.get routes through the enriched DTO builder rather than the bare +// dto.FromTask, which always defaults runner_ineligible_reason to +// evaluation_unavailable regardless of the task's real signals. +func TestWSGetTaskReturnsEvaluatedRunnerMutability(t *testing.T) { + repo := &runnerSwitchWSRepo{ + task: &models.Task{ID: "task-1", WorkspaceID: "ws-1", Title: "T"}, + } + h := newRunnerSwitchWSHandlers(t, repo) + + raw, err := json.Marshal(map[string]any{"id": "task-1"}) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + resp, err := h.wsGetTask(context.Background(), &ws.Message{ID: "msg-1", Action: ws.ActionTaskGet, Payload: raw}) + if err != nil { + t.Fatalf("wsGetTask: %v", err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %v, want response (payload: %s)", resp.Type, resp.Payload) + } + var body map[string]any + if unmarshalErr := json.Unmarshal(resp.Payload, &body); unmarshalErr != nil { + t.Fatalf("unmarshal response payload: %v", unmarshalErr) + } + if body["runner_ineligible_reason"] != string(models.RunnerReasonNoRepository) { + t.Fatalf("runner_ineligible_reason = %v, want %q (task has no repository attached)", + body["runner_ineligible_reason"], models.RunnerReasonNoRepository) + } +} + +func assertWSErrorCode(t *testing.T, resp *ws.Message, want string) { + t.Helper() + if resp.Type != ws.MessageTypeError { + t.Fatalf("response type = %v, want error (payload: %s)", resp.Type, resp.Payload) + } + var body struct { + Code string `json:"code"` + } + if err := json.Unmarshal(resp.Payload, &body); err != nil { + t.Fatalf("unmarshal error payload: %v", err) + } + if body.Code != want { + t.Fatalf("error code = %q, want %q (payload: %s)", body.Code, want, resp.Payload) + } +} + +func TestRunnerSwitchWSErrorMapsEvaluationUnavailable(t *testing.T) { + msg := &ws.Message{ID: "msg-1", Action: ws.ActionTaskRunner} + log := newTestLogger(t) + resp, err := runnerSwitchWSError(msg, errors.New("wrapped: "+repoerrors.ErrRunnerEvaluationUnavailable.Error()), log) + if err != nil { + t.Fatalf("runnerSwitchWSError: %v", err) + } + assertWSErrorCode(t, resp, ws.ErrorCodeInternalError) + + resp, err = runnerSwitchWSError(msg, repoerrors.ErrRunnerEvaluationUnavailable, log) + if err != nil { + t.Fatalf("runnerSwitchWSError: %v", err) + } + assertWSErrorCode(t, resp, ws.ErrorCodeUnavailable) +} + +// TestRunnerSwitchWSErrorSanitizesEvaluationUnavailableMessage regression-tests +// that a wrapped internal error (a DB failure, a transaction abort, ...) +// reaches the WS client as a fixed generic message, not the wrapped detail +// verbatim — mirroring wsUpdateTaskRepository's existing sanitization for +// opaque internal errors. +func TestRunnerSwitchWSErrorSanitizesEvaluationUnavailableMessage(t *testing.T) { + msg := &ws.Message{ID: "msg-1", Action: ws.ActionTaskRunner} + log := newTestLogger(t) + sensitive := "pq: connection to 10.0.0.5:5432 refused by remote host" + wrapped := fmt.Errorf("%w: %s", repoerrors.ErrRunnerEvaluationUnavailable, sensitive) + + resp, err := runnerSwitchWSError(msg, wrapped, log) + if err != nil { + t.Fatalf("runnerSwitchWSError: %v", err) + } + assertWSErrorCode(t, resp, ws.ErrorCodeUnavailable) + + var body struct { + Message string `json:"message"` + } + if unmarshalErr := json.Unmarshal(resp.Payload, &body); unmarshalErr != nil { + t.Fatalf("unmarshal error payload: %v", unmarshalErr) + } + if strings.Contains(body.Message, sensitive) { + t.Fatalf("error message leaked internal detail: %q", body.Message) + } +} diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index d8c0631c9e..674a5f051c 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -5,9 +5,11 @@ import ( "errors" "strings" + "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" "github.com/kandev/kandev/internal/task/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowmove "github.com/kandev/kandev/internal/workflow/move" @@ -280,7 +282,12 @@ func (h *TaskHandlers) wsGetTask(ctx context.Context, msg *ws.Message) (*ws.Mess if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task not found", nil) } - return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task)) + dtos, err := buildTaskDTOsWithSessionInfo(ctx, h.service, h.logger, h.foregroundActivity, h.taskParkedProjection, []*models.Task{task}) + if err != nil { + h.logger.Error("failed to build task DTO", zap.Error(err)) + return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task)) + } + return ws.NewResponse(msg.ID, msg.Action, dtos[0]) } type wsUpdateTaskRequest struct { @@ -521,3 +528,64 @@ func (h *TaskHandlers) wsUpdateTaskState(ctx context.Context, msg *ws.Message) ( } return ws.NewResponse(msg.ID, msg.Action, dto.FromTask(task)) } + +type wsUpdateTaskRunnerRequest struct { + ID string `json:"id"` + ExecutorProfileID string `json:"executor_profile_id"` +} + +// wsUpdateTaskRunner implements the task.runner action. The response DTO is +// built through buildTaskDTOsWithSessionInfo, not the bare dto.FromTask, so +// it carries the recomputed runner_editable/runner_ineligible_reason +// alongside every other enriched field. +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}) + if err != nil { + h.logger.Error("failed to build task DTO after runner switch", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to load updated task", nil) + } + return ws.NewResponse(msg.ID, msg.Action, dtos[0]) +} + +// runnerSwitchWSError maps SwitchTaskRunner's outcome vocabulary onto WS +// error codes, attaching the machine-readable reason under +// errorDetailKeyErrorCode so a client can present per-outcome copy without +// parsing the human-readable message. evaluation_unavailable can wrap an +// opaque internal error (a DB failure, a transaction abort), so its message +// is a fixed generic string with the real error logged server-side instead +// — the same sanitization wsUpdateTaskRepository applies to its own opaque +// internal errors. +func runnerSwitchWSError(msg *ws.Message, err error, log *logger.Logger) (*ws.Message, error) { + switch { + case errors.Is(err, service.ErrRunnerSwitchMalformed): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + case errors.Is(err, repoerrors.ErrTaskNotFound): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), nil) + case errors.Is(err, service.ErrForbidden): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, err.Error(), nil) + case errors.Is(err, service.ErrExecutorProfileInvalid): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + case errors.Is(err, repoerrors.ErrRunnerCompatibilityConflict): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), + map[string]interface{}{errorDetailKeyErrorCode: models.RunnerConflictTargetCannotMaterializeRepository}) + case errors.Is(err, repoerrors.ErrRunnerEvaluationUnavailable): + log.Warn("runner switch evaluation unavailable", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnavailable, "Unable to evaluate the runner switch right now, try again", nil) + } + var mutabilityErr *repoerrors.ErrRunnerMutabilityConflict + if errors.As(err, &mutabilityErr) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeConflict, err.Error(), + map[string]interface{}{errorDetailKeyErrorCode: mutabilityErr.Reason}) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to switch task runner", nil) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 39dd90d2a8..2cdb95d1f3 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1801,6 +1801,16 @@ type TaskSession struct { // Environment reference TaskEnvironmentID string `json:"task_environment_id,omitempty"` // FK to task_environments for shared env + // TaskRunnerResolvedFromTask marks a session whose executor profile came + // from task metadata rather than an explicit launch argument. It is + // transient and lets the persistence transaction reject a stale + // pre-lock resolution before inserting the session or environment. + TaskRunnerResolvedFromTask bool `json:"-"` + // TaskRunnerProfileAtResolution is the task metadata value observed when + // the session's runner was resolved. It is transient and paired with + // TaskRunnerResolvedFromTask for the persistence-time recheck. + TaskRunnerProfileAtResolution string `json:"-"` + // Workflow-related fields IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode diff --git a/apps/backend/internal/task/models/runner_mutability.go b/apps/backend/internal/task/models/runner_mutability.go new file mode 100644 index 0000000000..b40f3ff548 --- /dev/null +++ b/apps/backend/internal/task/models/runner_mutability.go @@ -0,0 +1,197 @@ +package models + +import ( + "context" + "errors" + "strings" + "time" +) + +// ErrTaskRunnerChanged tells a session preparer that the task's runner +// changed after it resolved the task and before the session transaction +// acquired the task lock. The caller must reload the task and retry before +// persisting a session or workspace environment. +var ErrTaskRunnerChanged = errors.New("task runner changed during session preparation") + +// Runner mutability reason codes. This is the closed vocabulary +// runner_ineligible_reason is always a member of: the ten ordered condition +// codes plus "eligible" and the fail-closed "evaluation_unavailable" +// backstop. +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" +) + +// RunnerConflictTargetCannotMaterializeRepository is the compatibility-gate +// outcome. It is target-dependent and, unlike the codes above, is never +// projected on the task's runner_ineligible_reason field. +const RunnerConflictTargetCannotMaterializeRepository = "target_cannot_materialize_repository" + +// WorkspaceModeNewWorkspace and WorkspaceModeSharedGroup are the two +// workspace-mode values EvaluateRunnerMutability's condition 10 inspects. +// Any other value (including WorkspaceModeInheritParent, defined in +// workspace_orphan.go, and the empty string) is treated as "not +// new_workspace" for that condition's purposes. +const ( + WorkspaceModeNewWorkspace = "new_workspace" + WorkspaceModeSharedGroup = "shared_group" +) + +// RunnerMutabilitySignals is the raw, task-scoped state the ten ordered +// conditions in EvaluateRunnerMutability read. It carries nothing derived +// from workflow state, step, or priority. +type RunnerMutabilitySignals struct { + Archived bool + RepositoryCount int + HasSession bool + HasEnvironment bool + HasExecutorRunning bool + HasWorkspaceFolder bool + WorkspacePath string + HasActiveGroupMembership bool + HasParent bool + // WorkspaceMode is the task's declared workspace mode, or "" when none + // is declared (an absent mode is treated as inherit_parent for + // independence purposes on a task with a parent). + WorkspaceMode string +} + +// RunnerMutabilityVerdict is the projected pair: always both present, reason +// always a member of the closed vocabulary, never the empty string. +type RunnerMutabilityVerdict struct { + Editable bool + Reason string +} + +// EvaluateRunnerMutability is the single implementation of the ordered +// condition list. Every caller — the projection and the switch action +// alike — uses this function, so a projected verdict and an enforced +// verdict cannot disagree. +// +// Condition 8's "non-empty" boundary for a stored workspace path is +// resolved here: a whitespace-only value is treated as not set, the same +// blank test applied to payload identifiers elsewhere in this feature. This +// boundary is deliberately left open: a false-immutable verdict is +// unrecoverable from the product, while a false-editable one risks nothing +// because nothing has materialized, so the boundary favors editable. +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} + } +} + +// runnerWorkspaceBindingIndependent implements condition 10: a task with no +// parent is independent unless it declares shared-group mode; a task with a +// parent is independent only when it declares new_workspace explicitly. +func runnerWorkspaceBindingIndependent(hasParent bool, mode string) bool { + if !hasParent { + return mode != WorkspaceModeSharedGroup + } + return mode == WorkspaceModeNewWorkspace +} + +// RunnerSignalsFromTask fills the fields of RunnerMutabilitySignals that +// live on the task row itself (archived, declared workspace path, declared +// workspace mode, has-parent) rather than in another table. The caller +// fills in RepositoryCount and the four row-existence checks, which the +// task row does not carry. +func RunnerSignalsFromTask(task *Task) RunnerMutabilitySignals { + return RunnerMutabilitySignals{ + Archived: task.ArchivedAt != nil, + WorkspacePath: runnerWorkspacePathFromMetadata(task.Metadata), + HasParent: task.ParentID != "", + WorkspaceMode: runnerWorkspaceModeFromMetadata(task.Metadata), + } +} + +func runnerWorkspacePathFromMetadata(metadata map[string]interface{}) string { + if v, ok := metadata[MetaKeyWorkspacePath].(string); ok { + return v + } + return "" +} + +// runnerWorkspaceModeFromMetadata reads the nested "workspace"."mode" block +// WorkspacePolicy.MetadataBlock (internal/task/service/handoff_service.go) +// writes onto the task row. +func runnerWorkspaceModeFromMetadata(metadata map[string]interface{}) string { + ws, ok := metadata["workspace"].(map[string]interface{}) + if !ok { + return "" + } + mode, _ := ws["mode"].(string) + return mode +} + +// RunnerSwitchRequest bundles a runner-switch write's inputs: the target +// profile, the compatibility gate's pre-transaction resolution, and the +// office-owned workspace-group membership check (mutability condition 9) +// that the task repository cannot reach directly — GroupMembershipChecker is +// called after the task row lock is acquired, so its answer is as current as +// every other condition's. +type RunnerSwitchRequest struct { + TaskID string + ExecutorProfileID string + // CompatibilityApplicable is true when the target executor requires a + // clone URL at all, independent of whether resolution could actually run. + CompatibilityApplicable bool + // CompatibilityChecked is true when resolution actually ran: the target + // executor required a clone URL and the task had exactly one repository + // attachment at resolution time. When false but CompatibilityApplicable + // is true, the gate was applicable but unresolved (the repository shape + // did not allow evaluation) and ResolvedRepository* are unused. + CompatibilityChecked bool + // CompatibilityResolutionFailed is true when the gate applies but + // resolution could not complete (a repository read failed, or the + // clone-URL lookup errored or timed out) rather than being skipped for a + // shape reason. Reported only after the mutability gate has passed, so a + // resolution failure never preempts a stable mutability conflict. + CompatibilityResolutionFailed bool + // CompatibilityCloneURLFound is meaningful only when CompatibilityChecked + // is true: whether resolution found a usable clone URL for + // ResolvedRepositoryID. + CompatibilityCloneURLFound bool + ResolvedRepositoryID string + ResolvedRepositoryUpdatedAt time.Time + GroupMembershipChecker func(ctx context.Context, taskID string) (bool, error) +} + +// RunnerSwitchResult is SwitchTaskRunner's success outcome. +type RunnerSwitchResult struct { + Task *Task + // Changed is false for the no-op success case: the requested profile + // already equals the stored one, both gates still passed, but nothing + // was written. + Changed bool +} diff --git a/apps/backend/internal/task/models/runner_mutability_test.go b/apps/backend/internal/task/models/runner_mutability_test.go new file mode 100644 index 0000000000..c87f3bb8c2 --- /dev/null +++ b/apps/backend/internal/task/models/runner_mutability_test.go @@ -0,0 +1,196 @@ +package models + +import ( + "testing" + "time" +) + +func eligibleSignals() RunnerMutabilitySignals { + return RunnerMutabilitySignals{RepositoryCount: 1} +} + +func TestEvaluateRunnerMutability_Eligible(t *testing.T) { + verdict := EvaluateRunnerMutability(eligibleSignals()) + if !verdict.Editable || verdict.Reason != RunnerReasonEligible { + t.Fatalf("verdict = %+v, want editable=true reason=%s", verdict, RunnerReasonEligible) + } +} + +func TestEvaluateRunnerMutability_ConditionOrder(t *testing.T) { + tests := []struct { + name string + mutate func(*RunnerMutabilitySignals) + wantErr string + }{ + {"archived", func(s *RunnerMutabilitySignals) { s.Archived = true }, RunnerReasonTaskArchived}, + {"no repository", func(s *RunnerMutabilitySignals) { s.RepositoryCount = 0 }, RunnerReasonNoRepository}, + {"multiple repositories", func(s *RunnerMutabilitySignals) { s.RepositoryCount = 2 }, RunnerReasonMultipleRepositories}, + {"session exists", func(s *RunnerMutabilitySignals) { s.HasSession = true }, RunnerReasonSessionExists}, + {"environment exists", func(s *RunnerMutabilitySignals) { s.HasEnvironment = true }, RunnerReasonEnvironmentExists}, + {"executor running", func(s *RunnerMutabilitySignals) { s.HasExecutorRunning = true }, RunnerReasonExecutorRunning}, + {"workspace folder attached", func(s *RunnerMutabilitySignals) { s.HasWorkspaceFolder = true }, RunnerReasonWorkspaceFolderAttached}, + {"workspace path set", func(s *RunnerMutabilitySignals) { s.WorkspacePath = "/host/path" }, RunnerReasonWorkspacePathSet}, + {"workspace group member", func(s *RunnerMutabilitySignals) { s.HasActiveGroupMembership = true }, RunnerReasonWorkspaceGroupMember}, + {"binding not independent (shared group, no parent)", func(s *RunnerMutabilitySignals) { s.WorkspaceMode = "shared_group" }, RunnerReasonWorkspaceBindingNotIndependent}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signals := eligibleSignals() + tt.mutate(&signals) + verdict := EvaluateRunnerMutability(signals) + if verdict.Editable { + t.Fatalf("Editable = true, want false for %s", tt.name) + } + if verdict.Reason != tt.wantErr { + t.Fatalf("Reason = %s, want %s", verdict.Reason, tt.wantErr) + } + }) + } +} + +// TestEvaluateRunnerMutability_FirstFailingConditionWins proves the ten +// conditions are evaluated in the fixed AC-TASKS-RUNNER-SWITCH-001.3 order: +// a task tripping several conditions always reports the earliest one. +func TestEvaluateRunnerMutability_FirstFailingConditionWins(t *testing.T) { + signals := RunnerMutabilitySignals{ + Archived: true, + RepositoryCount: 0, + HasSession: true, + HasEnvironment: true, + HasExecutorRunning: true, + HasWorkspaceFolder: true, + WorkspacePath: "/host/path", + HasActiveGroupMembership: true, + WorkspaceMode: "shared_group", + } + verdict := EvaluateRunnerMutability(signals) + if verdict.Reason != RunnerReasonTaskArchived { + t.Fatalf("Reason = %s, want %s (archived must win over every later condition)", verdict.Reason, RunnerReasonTaskArchived) + } + + signals.Archived = false + verdict = EvaluateRunnerMutability(signals) + if verdict.Reason != RunnerReasonNoRepository { + t.Fatalf("Reason = %s, want %s", verdict.Reason, RunnerReasonNoRepository) + } +} + +// TestEvaluateRunnerMutability_WorkspaceBindingIndependence covers every +// combination of HasParent x WorkspaceMode against AC-TASKS-RUNNER-SWITCH-001.3 +// condition 10: a task with no parent is independent unless it declares +// shared-group mode; a task with a parent is independent only when it +// declares new_workspace explicitly. +func TestEvaluateRunnerMutability_WorkspaceBindingIndependence(t *testing.T) { + tests := []struct { + name string + hasParent bool + mode string + wantIndependent bool + }{ + {"no parent, no mode", false, "", true}, + {"no parent, inherit_parent", false, "inherit_parent", true}, + {"no parent, new_workspace", false, "new_workspace", true}, + {"no parent, shared_group", false, "shared_group", false}, + {"parent, no mode", true, "", false}, + {"parent, inherit_parent", true, "inherit_parent", false}, + {"parent, shared_group", true, "shared_group", false}, + {"parent, new_workspace", true, "new_workspace", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signals := eligibleSignals() + signals.HasParent = tt.hasParent + signals.WorkspaceMode = tt.mode + verdict := EvaluateRunnerMutability(signals) + gotIndependent := verdict.Editable + if gotIndependent != tt.wantIndependent { + t.Fatalf("Editable = %v, want %v (independent=%v)", verdict.Editable, tt.wantIndependent, tt.wantIndependent) + } + if !tt.wantIndependent && verdict.Reason != RunnerReasonWorkspaceBindingNotIndependent { + t.Fatalf("Reason = %s, want %s", verdict.Reason, RunnerReasonWorkspaceBindingNotIndependent) + } + }) + } +} + +// TestEvaluateRunnerMutability_WorkspacePathBoundary pins the Build-time +// decision for F23 (spec left this boundary open): a stored workspace path +// that is empty or whitespace-only is treated as NOT set, the same "blank" +// test AC-TASKS-RUNNER-SWITCH-002.8 applies to payload identifiers. A +// permanently-unrecoverable false-immutable (whitespace trips the gate) is a +// worse product failure than a false-editable (whitespace does not trip it), +// since nothing has materialized yet and an editable task risks nothing but +// an extra no-op switch attempt. +func TestEvaluateRunnerMutability_WorkspacePathBoundary(t *testing.T) { + tests := []struct { + name string + path string + want bool // editable + }{ + {"empty", "", true}, + {"whitespace only", " \t\n", true}, + {"non-empty", "/host/path", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signals := eligibleSignals() + signals.WorkspacePath = tt.path + verdict := EvaluateRunnerMutability(signals) + if verdict.Editable != tt.want { + t.Fatalf("Editable = %v, want %v", verdict.Editable, tt.want) + } + }) + } +} + +func TestRunnerSignalsFromTask(t *testing.T) { + now := time.Now().UTC() + + t.Run("archived", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{ArchivedAt: &now}) + if !s.Archived { + t.Fatalf("Archived = false, want true") + } + }) + + t.Run("not archived", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{}) + if s.Archived { + t.Fatalf("Archived = true, want false") + } + }) + + t.Run("workspace path from metadata", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{Metadata: map[string]interface{}{ + MetaKeyWorkspacePath: "/host/path", + }}) + if s.WorkspacePath != "/host/path" { + t.Fatalf("WorkspacePath = %q, want /host/path", s.WorkspacePath) + } + }) + + t.Run("has parent", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{ParentID: "parent-1"}) + if !s.HasParent { + t.Fatalf("HasParent = false, want true") + } + }) + + t.Run("workspace mode from nested metadata block", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{Metadata: map[string]interface{}{ + "workspace": map[string]interface{}{"mode": "new_workspace"}, + }}) + if s.WorkspaceMode != "new_workspace" { + t.Fatalf("WorkspaceMode = %q, want new_workspace", s.WorkspaceMode) + } + }) + + t.Run("no metadata block", func(t *testing.T) { + s := RunnerSignalsFromTask(&Task{}) + if s.WorkspaceMode != "" || s.WorkspacePath != "" { + t.Fatalf("expected empty mode/path, got %+v", s) + } + }) +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 91df26fef4..b22645582e 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -175,6 +175,22 @@ type TaskRepository interface { // and bumps updated_at. Returns the task as it exists immediately after // the update, or nil if no task held the identity. ReleaseTaskExternalID(ctx context.Context, workspaceID, externalID string) (*models.Task, error) + + // SwitchTaskRunner re-evaluates the ten ordered mutability conditions + // inside a single task-row-locked transaction, confirms the + // compatibility gate's pre-transaction repository snapshot + // (req.ResolvedRepositoryID / ResolvedRepositoryUpdatedAt) is still + // current, and — only when every check passes — writes + // req.ExecutorProfileID as the task's sole metadata change. It never + // evaluates the compatibility gate itself; that runs before this call, + // outside any transaction. + // + // Returns *repoerrors.ErrRunnerMutabilityConflict when the mutability + // gate fails, repoerrors.ErrRunnerCompatibilityConflict when the + // compatibility gate fails, and repoerrors.ErrRunnerEvaluationUnavailable + // for a failed read, a stale compatibility snapshot, a failed lock, a + // failed write, or a failed commit. + SwitchTaskRunner(ctx context.Context, req models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) } // TaskPriorityRepository updates a task's priority without replacing the @@ -574,6 +590,11 @@ type ExecutorRepository interface { ListAllExecutorProfiles(ctx context.Context) ([]*models.ExecutorProfile, error) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) ListExecutorsRunningByTaskID(ctx context.Context, taskID string) ([]*models.ExecutorRunning, error) + // GetExecutorRunningExistenceByTaskIDs reports, for each of taskIDs, + // whether any executors_running row exists. Batched sibling of the + // single-task presence check the runner-mutability evaluator uses, for + // list/board projections that must not fan out into a per-task query. + GetExecutorRunningExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error @@ -614,6 +635,11 @@ type TaskEnvironmentRepository interface { CreateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) GetTaskEnvironmentByTaskID(ctx context.Context, taskID string) (*models.TaskEnvironment, error) + // GetTaskEnvironmentExistenceByTaskIDs reports, for each of taskIDs, + // whether any task_environments row exists. Batched sibling of the + // single-task presence check the runner-mutability evaluator uses, for + // list/board projections that must not fan out into a per-task query. + GetTaskEnvironmentExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) UpdateTaskEnvironment(ctx context.Context, env *models.TaskEnvironment) error DeleteTaskEnvironment(ctx context.Context, id string) error DeleteTaskEnvironmentsByTask(ctx context.Context, taskID string) error diff --git a/apps/backend/internal/task/repository/repoerrors/errors.go b/apps/backend/internal/task/repository/repoerrors/errors.go index 0b6d4e6e4f..7f20352d07 100644 --- a/apps/backend/internal/task/repository/repoerrors/errors.go +++ b/apps/backend/internal/task/repository/repoerrors/errors.go @@ -83,6 +83,33 @@ var ErrTaskCleanupInProgress = errors.New("task cleanup in progress") // contract. var ErrWorkflowResolutionConflict = errors.New("task workflow changed since resolution") +// ErrRunnerMutabilityConflict wraps one of the ten ordered mutability reason +// codes rejecting a runner switch. Reason is always a member of the same +// closed vocabulary the projection uses, never "eligible" and never empty. +type ErrRunnerMutabilityConflict struct { + Reason string +} + +func (e *ErrRunnerMutabilityConflict) Error() string { + return "runner switch rejected: " + e.Reason +} + +// ErrRunnerCompatibilityConflict reports that the mutability gate passed but +// the target runner cannot materialize the task's repository. Unlike +// ErrRunnerMutabilityConflict this code is never projected on the task's +// runner_ineligible_reason field — it describes the target, not the task. +var ErrRunnerCompatibilityConflict = errors.New("target cannot materialize repository") + +// ErrExecutorProfileNotFound reports that no executor profile row matched +// the supplied id. +var ErrExecutorProfileNotFound = errors.New("executor profile not found") + +// ErrRunnerEvaluationUnavailable reports that a runner switch could not be +// decided or applied — a failed read, a failed lock acquisition, a stale +// compatibility-gate snapshot, a failed metadata write, or a failed commit. +// It is the one retriable outcome: a caller may repeat the request. +var ErrRunnerEvaluationUnavailable = errors.New("runner switch evaluation unavailable") + // ErrStepChanged reports that a reorder's submitted band membership no // longer exactly matches the band's persisted membership // (REQ-TASKS-KANBAN-TASK-REORDERING-001.19). The whole request is rejected diff --git a/apps/backend/internal/task/repository/sqlite/batched_task_existence.go b/apps/backend/internal/task/repository/sqlite/batched_task_existence.go new file mode 100644 index 0000000000..7c231f7a79 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/batched_task_existence.go @@ -0,0 +1,44 @@ +package sqlite + +import ( + "context" + "fmt" +) + +// batchedTaskIDExistence reports, for each of taskIDs, whether at least one +// row in the named table carries that task_id — the shared shape behind +// every "does this task have an X" batched projection read (task +// environments, running executors), collapsed into one place because the +// two call sites were otherwise byte-for-byte identical. table is always a +// caller-supplied constant, never external input. Chunked via chunkIDs so a +// board or boot load with many tasks stays under SQLite's/PostgreSQL's +// per-statement bind-parameter limit instead of erroring the whole read. +func (r *Repository) batchedTaskIDExistence(ctx context.Context, table string, taskIDs []string) (map[string]bool, error) { + result := make(map[string]bool, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + for _, chunk := range chunkIDs(taskIDs, sqliteMaxHostParams) { + placeholders, args := buildInPlaceholders(chunk) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(fmt.Sprintf( + `SELECT DISTINCT task_id FROM %s WHERE task_id IN (%s)`, table, placeholders, + )), args...) + if err != nil { + return nil, err + } + for rows.Next() { + var taskID string + if err := rows.Scan(&taskID); err != nil { + _ = rows.Close() + return nil, err + } + result[taskID] = true + } + err = rows.Err() + _ = rows.Close() + if err != nil { + return nil, err + } + } + return result, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 42f6986e92..7166b8a8e5 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" + kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/recoveryclaim" @@ -139,6 +140,15 @@ func (r *Repository) ListExecutors(ctx context.Context) ([]*models.Executor, err return result, rows.Err() } +// UpsertExecutorRunning takes the shared task-row lock (kandevdb.LockTaskRowInTx) +// before writing so a concurrent runner switch cannot land between this +// write's mutability read and its own re-check — the two either fully +// precede or fully follow each other. A row with no +// TaskID (defensive only; every production caller populates it from the +// owning execution) skips the lock, matching guardWorkspaceSourceParentTx's +// no-parent case. It also locks the session row and, when the session names +// a task environment, rejects the write via recoveryclaim.EnsureAvailableTx +// while that environment is under an active recovery claim. func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error { if running == nil { return fmt.Errorf("executor running is nil") @@ -169,6 +179,13 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models. return err } defer func() { _ = tx.Rollback() }() + + if running.TaskID != "" { + if lockErr := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), running.TaskID); lockErr != nil && + !errors.Is(lockErr, kandevdb.ErrTaskRowNotFound) { + return lockErr + } + } if _, err := lockTaskSessionRow(ctx, tx, running.SessionID); err != nil { return err } @@ -302,6 +319,14 @@ func (r *Repository) ListExecutorsRunningByTaskID(ctx context.Context, taskID st return scanExecutorRunningRows(rows) } +// GetExecutorRunningExistenceByTaskIDs reports, for each of taskIDs, whether +// any executors_running row exists — the same unconditional presence check +// runnerHasExecutorRunning makes for one task, batched behind a single +// IN-clause query for a projection covering many. +func (r *Repository) GetExecutorRunningExistenceByTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) { + return r.batchedTaskIDExistence(ctx, "executors_running", taskIDs) +} + func (r *Repository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { if sessionID == "" { return nil, fmt.Errorf("session_id is required") diff --git a/apps/backend/internal/task/repository/sqlite/executor_profile.go b/apps/backend/internal/task/repository/sqlite/executor_profile.go index fee6f1028f..fa36a7ee41 100644 --- a/apps/backend/internal/task/repository/sqlite/executor_profile.go +++ b/apps/backend/internal/task/repository/sqlite/executor_profile.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" ) const jsonNull = "null" @@ -54,7 +55,7 @@ func (r *Repository) GetExecutorProfile(ctx context.Context, id string) (*models &configJSON, &profile.PrepareScript, &profile.CleanupScript, &envVarsJSON, &profile.CreatedAt, &profile.UpdatedAt, ) if err == sql.ErrNoRows { - return nil, fmt.Errorf("executor profile not found: %s", id) + return nil, fmt.Errorf("%w: %s", repoerrors.ErrExecutorProfileNotFound, id) } if err != nil { return nil, err diff --git a/apps/backend/internal/task/repository/sqlite/executor_running_lock_postgres_test.go b/apps/backend/internal/task/repository/sqlite/executor_running_lock_postgres_test.go new file mode 100644 index 0000000000..3a47bf65de --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/executor_running_lock_postgres_test.go @@ -0,0 +1,174 @@ +package sqlite + +// Postgres proof that UpsertExecutorRunning's own row-lock retrofit +// (internal/db.LockTaskRowInTx, called with r.db.DriverName() — see the doc +// comment on UpsertExecutorRunning in executor.go) is a genuine PostgreSQL +// `FOR UPDATE` wait, not merely correct by construction on SQLite's +// single-writer-connection no-op. Skips unless KANDEV_TEST_POSTGRES_DSN is +// set; CI runs this in postgres-boot. +// +// runner_switch_postgres_test.go already proves this for SwitchTaskRunner +// itself, with a holder that plays a concurrent class-2 writer. This test +// proves the other side of that same serialization guarantee: it holds the +// row lock with the same raw LockTaskRowInTx helper and runs the +// unmodified, production UpsertExecutorRunning as the mover, so a class-2 +// write's own lock acquisition — not just SwitchTaskRunner's — is verified +// against a real PostgreSQL FOR UPDATE wait. UpsertExecutorRunning has no +// injection point to pause mid-transaction (BeginTxx, lock, write, and the +// caller's own commit all happen inside one call), so it cannot play the +// holder role the way SwitchTaskRunner's sibling test does; it can only be +// the mover here. + +import ( + "context" + "sync" + "testing" + "time" + + kandevdb "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresUpsertExecutorRunningBlocksOnConcurrentRowLock(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + db := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-exec-running-lock", Name: "PG Executor Running Lock Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + task := &models.Task{ + ID: "task-pg-exec-running-lock", WorkspaceID: "ws-pg-exec-running-lock", Title: "PG Executor Running Lock Task", + Priority: "medium", + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + // Independent connections, not repo's single isolated connection: see + // openSecondPostgresConnection's doc comment for why a shared connection + // would serialize the calls at Go's pool rather than at the PostgreSQL + // server, proving nothing about the FOR UPDATE clause itself. + holderDB := openSecondPostgresConnection(t, dsn, db) + moverDB := openSecondPostgresConnection(t, dsn, db) + observerDB := openSecondPostgresConnection(t, dsn, db) + moverRepo, err := NewWithDB(moverDB, moverDB, nil) + if err != nil { + t.Fatalf("init postgres schema on mover connection: %v", err) + } + + // The holder plays a concurrent runner switch mid-transaction: it + // acquires the row's FOR UPDATE lock via the exact production helper + // UpsertExecutorRunning itself calls, then holds the transaction + // open — uncommitted — until this test explicitly releases it. + lockHeld := make(chan struct{}) + releaseLock := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseLock) }) } + holderFinished := make(chan struct{}) + var holderErr error + go func() { + defer close(holderFinished) + tx, err := holderDB.BeginTxx(ctx, nil) + if err != nil { + holderErr = err + return + } + if err := kandevdb.LockTaskRowInTx(ctx, tx, holderDB.DriverName(), task.ID); err != nil { + _ = tx.Rollback() + holderErr = err + return + } + close(lockHeld) + <-releaseLock + holderErr = tx.Commit() + }() + t.Cleanup(func() { + release() + select { + case <-holderFinished: + if holderErr != nil { + t.Errorf("holder goroutine during cleanup: %v", holderErr) + } + case <-time.After(5 * time.Second): + t.Errorf("timed out waiting for holder goroutine during cleanup") + } + }) + + select { + case <-lockHeld: + case <-holderFinished: + t.Fatalf("holder goroutine failed before acquiring the row lock: %v", holderErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the holder goroutine to acquire the row lock") + } + + var moverPID int + if err := moverDB.Get(&moverPID, "SELECT pg_backend_pid()"); err != nil { + t.Fatalf("read mover backend pid: %v", err) + } + + // The mover runs the unmodified production UpsertExecutorRunning on the + // second, genuinely independent connection, exactly as a real executor + // launch would. + moverErr := make(chan error, 1) + moverFinished := make(chan struct{}) + go func() { + defer close(moverFinished) + moverErr <- moverRepo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + SessionID: "session-pg-exec-running-lock", + TaskID: task.ID, + ExecutorID: "executor-pg-exec-running-lock", + Status: "starting", + }) + }() + t.Cleanup(func() { + release() + select { + case <-moverFinished: + case <-time.After(5 * time.Second): + t.Errorf("timed out waiting for mover goroutine during cleanup") + } + }) + + if err := waitForPostgresLock(ctx, observerDB, moverPID, moverFinished); err != nil { + t.Fatal(err) + } + select { + case <-moverFinished: + t.Fatal("UpsertExecutorRunning returned while the holder still held the row's FOR UPDATE lock — its LockTaskRowInTx call is not genuinely serialized against a concurrent PostgreSQL writer") + default: + } + + release() + select { + case <-holderFinished: + if holderErr != nil { + t.Fatalf("holder goroutine: %v", holderErr) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for holder goroutine after releasing the row lock") + } + + select { + case <-moverFinished: + if err := <-moverErr; err != nil { + t.Fatalf("UpsertExecutorRunning (after lock release) error = %v, want nil", err) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UpsertExecutorRunning to proceed after the lock was released") + } + + rows, err := repo.ListExecutorsRunningByTaskID(ctx, task.ID) + if err != nil { + t.Fatalf("ListExecutorsRunningByTaskID: %v", err) + } + if len(rows) != 1 || rows[0].SessionID != "session-pg-exec-running-lock" { + t.Fatalf("executors_running after release = %#v, want one row for session-pg-exec-running-lock", rows) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/runner_switch.go b/apps/backend/internal/task/repository/sqlite/runner_switch.go new file mode 100644 index 0000000000..c21cf2eded --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runner_switch.go @@ -0,0 +1,247 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + + kandevdb "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// SwitchTaskRunner implements repository.TaskRepository.SwitchTaskRunner. +// See the interface doc comment for the contract; this method is the single +// transaction system-design's "Control flow" steps 5-7 describe. +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: + // The gate applies to this target, but a lookup pre-transaction + // resolution needed (a repository read, or the clone-URL candidate + // lookup) errored or timed out. Reported only here, after the + // mutability gate above has already passed, so a task that is also + // ineligible for a stable reason reports that reason instead of this + // retriable one. + 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: + // The gate applies to this target but pre-transaction resolution + // skipped it because the repository shape didn't allow evaluation + // then. Reaching this point already means the mutability gate found + // exactly one repository now (its own RepositoryCount==0/>1 checks + // come first and would have rejected otherwise), so the shape did + // change since resolution and that stale skip cannot be trusted. + return nil, fmt.Errorf("%w: repository shape changed since compatibility resolution", 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 +} + +func (r *Repository) runnerSwitchReadTaskTx(ctx context.Context, tx *sqlx.Tx, taskID string) (*models.Task, error) { + row := tx.QueryRowContext(ctx, r.db.Rebind( + `SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.id = ?`), taskID) + task, err := r.scanSingleTask(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID) + } + return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) + } + return task, nil +} + +// runnerRepositoryLinkSnapshot is the single attached repository's identity +// and last-updated column, read inside the switch transaction. Empty when +// the task does not have exactly one repository at that moment. +type runnerRepositoryLinkSnapshot struct { + count int + repositoryID string + updatedAt time.Time +} + +// runnerSwitchEvaluate gathers every mutability signal inside the open +// transaction (after the row lock, so every read is as current as the write +// it guards) and evaluates the mutability gate. +func (r *Repository) runnerSwitchEvaluate( + ctx context.Context, req models.RunnerSwitchRequest, task *models.Task, +) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) { + unavailable := func(err error) (models.RunnerMutabilityVerdict, runnerRepositoryLinkSnapshot, error) { + return models.RunnerMutabilityVerdict{}, runnerRepositoryLinkSnapshot{}, + fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) + } + + repoSnapshot, err := r.runnerRepositoryLinkSnapshot(ctx, task.ID) + if err != nil { + return unavailable(err) + } + hasSession, err := r.runnerHasSession(ctx, task.ID) + if err != nil { + return unavailable(err) + } + hasEnvironment, err := r.runnerHasEnvironment(ctx, task.ID) + if err != nil { + return unavailable(err) + } + hasExecutorRunning, err := r.runnerHasExecutorRunning(ctx, task.ID) + if err != nil { + return unavailable(err) + } + hasWorkspaceFolder, err := r.runnerHasWorkspaceFolder(ctx, task.ID) + if err != nil { + return unavailable(err) + } + hasGroupMembership, err := runnerHasGroupMembership(ctx, req, task.ID) + if err != nil { + return unavailable(err) + } + + signals := models.RunnerSignalsFromTask(task) + signals.RepositoryCount = repoSnapshot.count + signals.HasSession = hasSession + signals.HasEnvironment = hasEnvironment + signals.HasExecutorRunning = hasExecutorRunning + signals.HasWorkspaceFolder = hasWorkspaceFolder + signals.HasActiveGroupMembership = hasGroupMembership + + return models.EvaluateRunnerMutability(signals), repoSnapshot, nil +} + +func runnerHasGroupMembership(ctx context.Context, req models.RunnerSwitchRequest, taskID string) (bool, error) { + if req.GroupMembershipChecker == nil { + return false, nil + } + return req.GroupMembershipChecker(ctx, taskID) +} + +// runnerSwitchConfirmCompatibility enforces that a compatibility verdict +// resolved before the transaction is only valid for the repository it was +// resolved against. A mismatch discards the verdict and rejects as the +// retriable evaluation_unavailable rather than applying a verdict computed +// against a repository the task no longer has. +func runnerSwitchConfirmCompatibility(req models.RunnerSwitchRequest, snapshot runnerRepositoryLinkSnapshot) error { + if snapshot.count != 1 || + snapshot.repositoryID != req.ResolvedRepositoryID || + !snapshot.updatedAt.Equal(req.ResolvedRepositoryUpdatedAt) { + return fmt.Errorf("%w: repository link changed since compatibility resolution", repoerrors.ErrRunnerEvaluationUnavailable) + } + if !req.CompatibilityCloneURLFound { + return repoerrors.ErrRunnerCompatibilityConflict + } + return nil +} + +// runnerSwitchApply is stage 7 of the control flow: compare the requested +// profile against the stored one and either report a no-op success or write +// the sole metadata change. +func (r *Repository) runnerSwitchApply( + ctx context.Context, tx *sqlx.Tx, task *models.Task, executorProfileID string, +) (*models.RunnerSwitchResult, error) { + stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + if stored == executorProfileID { + return &models.RunnerSwitchResult{Task: task, Changed: false}, nil + } + now := time.Now().UTC() + if err := r.setTaskMetadataKeyWithExecutor(ctx, tx, task.ID, models.MetaKeyExecutorProfileID, executorProfileID, now); err != nil { + return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) + } + if task.Metadata == nil { + task.Metadata = map[string]interface{}{} + } + task.Metadata[models.MetaKeyExecutorProfileID] = executorProfileID + task.UpdatedAt = now + return &models.RunnerSwitchResult{Task: task, Changed: true}, nil +} + +// runnerRepositoryLinkSnapshot reads through the reader pool (r.ro), not a +// transaction. Isolation for this read comes from the task row lock the +// caller already holds (LockTaskRowInTx in SwitchTaskRunner), not from +// executing inside tx. +func (r *Repository) runnerRepositoryLinkSnapshot(ctx context.Context, taskID string) (runnerRepositoryLinkSnapshot, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind( + `SELECT repository_id, updated_at FROM task_repositories WHERE task_id = ?`), taskID) + if err != nil { + return runnerRepositoryLinkSnapshot{}, err + } + defer func() { _ = rows.Close() }() + + var snapshot runnerRepositoryLinkSnapshot + for rows.Next() { + var repositoryID string + var updatedAt time.Time + if err := rows.Scan(&repositoryID, &updatedAt); err != nil { + return runnerRepositoryLinkSnapshot{}, err + } + snapshot.count++ + snapshot.repositoryID = repositoryID + snapshot.updatedAt = updatedAt + } + return snapshot, rows.Err() +} + +func (r *Repository) runnerHasSession(ctx context.Context, taskID string) (bool, error) { + var exists bool + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT EXISTS (SELECT 1 FROM task_sessions WHERE task_id = ?)`), taskID).Scan(&exists) + return exists, err +} + +func (r *Repository) runnerHasEnvironment(ctx context.Context, taskID string) (bool, error) { + var exists bool + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT EXISTS (SELECT 1 FROM task_environments WHERE task_id = ?)`), taskID).Scan(&exists) + return exists, err +} + +func (r *Repository) runnerHasExecutorRunning(ctx context.Context, taskID string) (bool, error) { + var exists bool + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT EXISTS (SELECT 1 FROM executors_running WHERE task_id = ?)`), taskID).Scan(&exists) + return exists, err +} + +func (r *Repository) runnerHasWorkspaceFolder(ctx context.Context, taskID string) (bool, error) { + var exists bool + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT EXISTS (SELECT 1 FROM task_workspace_folders WHERE task_id = ?)`), taskID).Scan(&exists) + return exists, err +} diff --git a/apps/backend/internal/task/repository/sqlite/runner_switch_batched_reads_test.go b/apps/backend/internal/task/repository/sqlite/runner_switch_batched_reads_test.go new file mode 100644 index 0000000000..96e48b218c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runner_switch_batched_reads_test.go @@ -0,0 +1,128 @@ +package sqlite + +import ( + "context" + "fmt" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// seedRunnerBatchTasks creates a workspace and two tasks in it, returning +// their IDs. +func seedRunnerBatchTasks(t *testing.T, repo *Repository, workspaceID string) (taskA, taskB string) { + t.Helper() + ctx := context.Background() + seedWorkspace(t, repo, workspaceID) + taskA, taskB = workspaceID+"-task-a", workspaceID+"-task-b" + for _, id := range []string{taskA, taskB} { + if err := repo.CreateTask(ctx, &models.Task{ID: id, WorkspaceID: workspaceID, Title: id}); err != nil { + t.Fatalf("CreateTask(%s): %v", id, err) + } + } + return taskA, taskB +} + +func TestGetTaskEnvironmentExistenceByTaskIDs(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + taskA, taskB := seedRunnerBatchTasks(t, repo, "ws-env-existence") + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-existence-a", TaskID: taskA, ExecutorType: string(models.ExecutorTypeLocal), Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + + got, err := repo.GetTaskEnvironmentExistenceByTaskIDs(ctx, []string{taskA, taskB, "task-not-in-batch"}) + if err != nil { + t.Fatalf("GetTaskEnvironmentExistenceByTaskIDs: %v", err) + } + if !got[taskA] { + t.Errorf("task with environment reported false") + } + if got[taskB] { + t.Errorf("task without environment reported true") + } + if got["task-not-in-batch"] { + t.Errorf("unknown task ID present in result") + } +} + +func TestGetTaskEnvironmentExistenceByTaskIDsEmptyInput(t *testing.T) { + repo := newRepoForEntityTests(t) + got, err := repo.GetTaskEnvironmentExistenceByTaskIDs(context.Background(), nil) + if err != nil { + t.Fatalf("GetTaskEnvironmentExistenceByTaskIDs(nil): %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty map for empty input, got %#v", got) + } +} + +func TestGetExecutorRunningExistenceByTaskIDs(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + taskA, taskB := seedRunnerBatchTasks(t, repo, "ws-exec-running-existence") + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "exec-running-a", SessionID: "sess-exec-running-a", TaskID: taskA, ExecutorID: models.ExecutorIDLocal, + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + + got, err := repo.GetExecutorRunningExistenceByTaskIDs(ctx, []string{taskA, taskB}) + if err != nil { + t.Fatalf("GetExecutorRunningExistenceByTaskIDs: %v", err) + } + if !got[taskA] { + t.Errorf("task with a running executor reported false") + } + if got[taskB] { + t.Errorf("task without a running executor reported true") + } +} + +func TestGetExecutorRunningExistenceByTaskIDsEmptyInput(t *testing.T) { + repo := newRepoForEntityTests(t) + got, err := repo.GetExecutorRunningExistenceByTaskIDs(context.Background(), []string{}) + if err != nil { + t.Fatalf("GetExecutorRunningExistenceByTaskIDs([]): %v", err) + } + if len(got) != 0 { + t.Fatalf("expected empty map for empty input, got %#v", got) + } +} + +// TestGetExecutorRunningExistenceByTaskIDsChunksAcrossHostParamLimit proves +// batchedTaskIDExistence's chunking merges results correctly when the +// caller-supplied task ID count exceeds sqliteMaxHostParams: a boot or board +// load for a large workflow must not error the whole read, and a hit must +// still be found regardless of which chunk it falls into. +func TestGetExecutorRunningExistenceByTaskIDsChunksAcrossHostParamLimit(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + taskA, _ := seedRunnerBatchTasks(t, repo, "ws-exec-running-chunked") + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "exec-running-chunked-a", SessionID: "sess-exec-running-chunked-a", TaskID: taskA, ExecutorID: models.ExecutorIDLocal, + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + + // Pad well past sqliteMaxHostParams (500) so the read spans at least two + // chunks, with the real hit placed last so it lands in the final chunk. + taskIDs := make([]string, 0, sqliteMaxHostParams*2+1) + for i := 0; i < sqliteMaxHostParams*2; i++ { + taskIDs = append(taskIDs, fmt.Sprintf("padding-task-%d", i)) + } + taskIDs = append(taskIDs, taskA) + + got, err := repo.GetExecutorRunningExistenceByTaskIDs(ctx, taskIDs) + if err != nil { + t.Fatalf("GetExecutorRunningExistenceByTaskIDs with %d IDs: %v", len(taskIDs), err) + } + if !got[taskA] { + t.Errorf("task with a running executor reported false across chunked read") + } + if len(got) != 1 { + t.Errorf("expected exactly one hit across chunked read, got %d: %#v", len(got), got) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/runner_switch_comparison_target_lock_test.go b/apps/backend/internal/task/repository/sqlite/runner_switch_comparison_target_lock_test.go new file mode 100644 index 0000000000..1f573bd88f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runner_switch_comparison_target_lock_test.go @@ -0,0 +1,177 @@ +package sqlite + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + kandevdb "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" +) + +// TestSwitchTaskRunner_ConcurrentBaseBranchUpdateNeverBlendsWithSwitch covers +// the in-place task-repository link update class-2 writer added by this +// change: UpdateTaskRepositoryBaseBranchAndClearComparisonTarget mutates the +// same link a runner switch's compatibility re-check reads, so the two must +// serialize on the task row lock and produce only the two outcomes +// AC-TASKS-RUNNER-SWITCH-002.3a permits. +func TestSwitchTaskRunner_ConcurrentBaseBranchUpdateNeverBlendsWithSwitch(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + var wg sync.WaitGroup + var switchErr, branchErr error + wg.Add(2) + go func() { + defer wg.Done() + _, switchErr = repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + }() + go func() { + defer wg.Done() + _, _, branchErr = repo.UpdateTaskRepositoryBaseBranchAndClearComparisonTarget(ctx, taskRepo.ID, "develop") + }() + wg.Wait() + + if branchErr != nil { + t.Fatalf("UpdateTaskRepositoryBaseBranchAndClearComparisonTarget error = %v, want nil", branchErr) + } + + switchRejectedAsStale := false + if switchErr != nil { + if !errors.Is(switchErr, repoerrors.ErrRunnerEvaluationUnavailable) { + t.Fatalf("switch error = %v, want nil or ErrRunnerEvaluationUnavailable", switchErr) + } + switchRejectedAsStale = true + } + + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + + if switchRejectedAsStale { + if stored != "profile-old" { + t.Fatalf("switch was rejected but stored profile = %q, want unchanged profile-old", stored) + } + return + } + if stored != "profile-new" { + t.Fatalf("switch committed but stored profile = %q, want profile-new", stored) + } +} + +func TestPostgresRepositoryWritersUseConsistentTaskThenLinkLockOrder(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + db := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-link-order", Name: "PG Link Order Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-pg-link-order", WorkspaceID: "ws-pg-link-order", Name: "pg-link-order-repo"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + task := &models.Task{ID: "task-pg-link-order", WorkspaceID: "ws-pg-link-order", Title: "PG Link Order Task", Priority: "medium"} + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + link := &models.TaskRepository{ID: "task-repo-pg-link-order", TaskID: task.ID, RepositoryID: "repo-pg-link-order", BaseBranch: "main"} + if err := repo.CreateTaskRepository(ctx, link); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } + + holderDB := openSecondPostgresConnection(t, dsn, db) + updateDB := openSecondPostgresConnection(t, dsn, db) + comparisonDB := openSecondPostgresConnection(t, dsn, db) + updateRepo, err := NewWithDB(updateDB, updateDB, nil) + if err != nil { + t.Fatalf("init update repository: %v", err) + } + comparisonRepo, err := NewWithDB(comparisonDB, comparisonDB, nil) + if err != nil { + t.Fatalf("init comparison repository: %v", err) + } + + holderTx, err := holderDB.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin holder transaction: %v", err) + } + if err := kandevdb.LockTaskRowInTx(ctx, holderTx, holderDB.DriverName(), task.ID); err != nil { + _ = holderTx.Rollback() + t.Fatalf("lock task row: %v", err) + } + released := false + t.Cleanup(func() { + if !released { + _ = holderTx.Rollback() + } + }) + + updatedLink := *link + updatedLink.BaseBranch = "develop" + var updatePID int + if err := updateDB.Get(&updatePID, "SELECT pg_backend_pid()"); err != nil { + t.Fatalf("read update backend pid: %v", err) + } + var comparisonPID int + if err := comparisonDB.Get(&comparisonPID, "SELECT pg_backend_pid()"); err != nil { + t.Fatalf("read comparison backend pid: %v", err) + } + + updateErr := make(chan error, 1) + updateFinished := make(chan struct{}) + go func() { + defer close(updateFinished) + updateErr <- updateRepo.UpdateTaskRepository(ctx, &updatedLink) + }() + if err := waitForPostgresLock(ctx, db, updatePID, updateFinished); err != nil { + t.Fatal(err) + } + + comparisonErr := make(chan error, 1) + comparisonFinished := make(chan struct{}) + go func() { + defer close(comparisonFinished) + _, _, err := comparisonRepo.UpdateTaskRepositoryBaseBranchAndClearComparisonTarget(ctx, link.ID, "release") + comparisonErr <- err + }() + if err := waitForPostgresLock(ctx, db, comparisonPID, comparisonFinished); err != nil { + t.Fatal(err) + } + + if err := holderTx.Commit(); err != nil { + t.Fatalf("release task lock: %v", err) + } + released = true + + select { + case err := <-updateErr: + if err != nil { + t.Fatalf("UpdateTaskRepository: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UpdateTaskRepository") + } + select { + case err := <-comparisonErr: + if err != nil { + t.Fatalf("UpdateTaskRepositoryBaseBranchAndClearComparisonTarget: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for UpdateTaskRepositoryBaseBranchAndClearComparisonTarget") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/runner_switch_postgres_test.go b/apps/backend/internal/task/repository/sqlite/runner_switch_postgres_test.go new file mode 100644 index 0000000000..1dae65e1b7 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runner_switch_postgres_test.go @@ -0,0 +1,197 @@ +package sqlite + +// Postgres proof that SwitchTaskRunner's row lock (internal/db.LockTaskRowInTx, +// called with r.db.DriverName()) is a genuine PostgreSQL `FOR UPDATE` wait, not +// merely correct by construction on SQLite's single-writer-connection no-op. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set; CI runs this in postgres-boot. +// +// runner_switch_test.go's concurrency tests (e.g. +// TestSwitchTaskRunner_ConcurrentExecutorRunningWriteNeverBlendsWithSwitch) all +// run against SQLite, where LockTaskRowInTx is a deliberate no-op relying on +// the writer pool's MaxOpenConns(1) — they prove SwitchTaskRunner's own +// sequencing but never exercise the `dialect.IsPostgres` branch or the real +// `SELECT ... FOR UPDATE` this feature adds to internal/db/tasklock.go. This +// test mirrors the holder/mover/observer shape in +// task_update_if_workflow_matches_postgres_test.go and +// turn_step_stamp_postgres_test.go (openSecondPostgresConnection, +// waitForPostgresLock): one goroutine holds the task row's lock open via the +// same internal/db.LockTaskRowInTx production helper on an independent +// connection, and a second, independent connection runs the real +// SwitchTaskRunner. The test observes the mover's backend enter a genuine +// PostgreSQL lock wait before releasing the holder, so a scheduler delay can't +// masquerade as contention, then asserts the switch applies only after release +// and the holder's own row read is unaffected by the switch's later write. + +import ( + "context" + "sync" + "testing" + "time" + + kandevdb "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresSwitchTaskRunnerBlocksOnConcurrentRowLock(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + db := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-runner-switch", Name: "PG Runner Switch Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-pg-runner-switch", WorkspaceID: "ws-pg-runner-switch", Name: "pg-runner-switch-repo"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + task := &models.Task{ + ID: "task-pg-runner-switch", WorkspaceID: "ws-pg-runner-switch", Title: "PG Runner Switch Task", + Priority: "medium", + Metadata: map[string]interface{}{models.MetaKeyExecutorProfileID: "profile-old"}, + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-pg-runner-switch", TaskID: task.ID, RepositoryID: "repo-pg-runner-switch", BaseBranch: "main", + }); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } + + // Independent connections, not repo's single isolated connection: see + // openSecondPostgresConnection's doc comment for why a shared connection + // would serialize the calls at Go's pool rather than at the PostgreSQL + // server, proving nothing about the FOR UPDATE clause itself. The mover + // needs its own writer AND reader connection: SwitchTaskRunner holds an + // open r.db transaction while reading other tables via r.ro, and a + // shared single connection between the two would self-deadlock under + // MaxOpenConns(1) exactly as runner_switch_test.go's doc comment + // describes for SQLite. + holderDB := openSecondPostgresConnection(t, dsn, db) + moverWriterDB := openSecondPostgresConnection(t, dsn, db) + moverReaderDB := openSecondPostgresConnection(t, dsn, db) + observerDB := openSecondPostgresConnection(t, dsn, db) + moverRepo, err := NewWithDB(moverWriterDB, moverReaderDB, nil) + if err != nil { + t.Fatalf("init postgres schema on mover connection: %v", err) + } + + // The holder plays a concurrent class-2 writer (e.g. UpsertExecutorRunning) + // mid-transaction: it acquires the row's FOR UPDATE lock via the exact + // production helper SwitchTaskRunner itself calls, then holds the + // transaction open — uncommitted — until this test explicitly releases it. + lockHeld := make(chan struct{}) + releaseLock := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseLock) }) } + holderFinished := make(chan struct{}) + var holderErr error + go func() { + defer close(holderFinished) + tx, err := holderDB.BeginTxx(ctx, nil) + if err != nil { + holderErr = err + return + } + if err := kandevdb.LockTaskRowInTx(ctx, tx, holderDB.DriverName(), task.ID); err != nil { + _ = tx.Rollback() + holderErr = err + return + } + close(lockHeld) + <-releaseLock + holderErr = tx.Commit() + }() + t.Cleanup(func() { + release() + select { + case <-holderFinished: + if holderErr != nil { + t.Errorf("holder goroutine during cleanup: %v", holderErr) + } + case <-time.After(5 * time.Second): + t.Errorf("timed out waiting for holder goroutine during cleanup") + } + }) + + select { + case <-lockHeld: + case <-holderFinished: + t.Fatalf("holder goroutine failed before acquiring the row lock: %v", holderErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the holder goroutine to acquire the row lock") + } + + var moverPID int + if err := moverWriterDB.Get(&moverPID, "SELECT pg_backend_pid()"); err != nil { + t.Fatalf("read mover backend pid: %v", err) + } + + // The mover runs the unmodified production SwitchTaskRunner on the + // second, genuinely independent connection pair, exactly as the real + // task.runner action does. + moverDone := make(chan *models.RunnerSwitchResult, 1) + moverErr := make(chan error, 1) + moverFinished := make(chan struct{}) + go func() { + defer close(moverFinished) + result, err := moverRepo.SwitchTaskRunner(ctx, models.RunnerSwitchRequest{ + TaskID: task.ID, + ExecutorProfileID: "profile-new", + }) + moverDone <- result + moverErr <- err + }() + t.Cleanup(func() { + release() + select { + case <-moverFinished: + case <-time.After(5 * time.Second): + t.Errorf("timed out waiting for mover goroutine during cleanup") + } + }) + + if err := waitForPostgresLock(ctx, observerDB, moverPID, moverFinished); err != nil { + t.Fatal(err) + } + select { + case <-moverFinished: + t.Fatal("SwitchTaskRunner returned while the holder still held the row's FOR UPDATE lock — LockTaskRowInTx is not genuinely serialized against a concurrent PostgreSQL writer") + default: + } + + release() + select { + case <-holderFinished: + if holderErr != nil { + t.Fatalf("holder goroutine: %v", holderErr) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for holder goroutine after releasing the row lock") + } + + select { + case <-moverFinished: + if err := <-moverErr; err != nil { + t.Fatalf("SwitchTaskRunner (after lock release) error = %v, want nil", err) + } + result := <-moverDone + if !result.Changed { + t.Fatalf("result.Changed = false, want true") + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SwitchTaskRunner to proceed after the lock was released") + } + + final, err := repo.GetTask(ctx, task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if got := final.Metadata[models.MetaKeyExecutorProfileID]; got != "profile-new" { + t.Fatalf("persisted executor_profile_id = %v, want profile-new (the switch must apply once the concurrent PostgreSQL lock is released)", got) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/runner_switch_test.go b/apps/backend/internal/task/repository/sqlite/runner_switch_test.go new file mode 100644 index 0000000000..3c1550644d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/runner_switch_test.go @@ -0,0 +1,828 @@ +package sqlite + +import ( + "context" + "errors" + "path/filepath" + "sync" + "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" +) + +// newRunnerSwitchTestRepo opens separate writer and reader connections +// against the same file, mirroring production's dual-pool setup +// (internal/db.Pool). SwitchTaskRunner reads other tables via r.ro while +// holding an open r.db transaction; a single shared *sqlx.DB for both (as +// some older test helpers use) self-deadlocks under the writer pool's +// MaxOpenConns(1), since the held transaction already owns the only +// connection the read would need. +func newRunnerSwitchTestRepo(t *testing.T) *Repository { + t.Helper() + path := filepath.Join(t.TempDir(), "runner-switch.db") + writerConn, err := dbutil.OpenSQLite(path) + if err != nil { + t.Fatalf("open sqlite writer: %v", err) + } + writer := sqlx.NewDb(writerConn, "sqlite3") + t.Cleanup(func() { _ = writer.Close() }) + + readerConn, err := dbutil.OpenSQLiteReader(path) + if err != nil { + t.Fatalf("open sqlite reader: %v", err) + } + reader := sqlx.NewDb(readerConn, "sqlite3") + t.Cleanup(func() { _ = reader.Close() }) + + repo, err := NewWithDB(writer, reader, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + return repo +} + +func seedRunnerSwitchWorkspace(t *testing.T, repo *Repository, workspaceID string) { + t.Helper() + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`), + workspaceID, workspaceID, now, now); err != nil { + t.Fatalf("seed workspace: %v", err) + } +} + +type seedRunnerSwitchTaskOpts struct { + ParentID string + Metadata string + Archived bool +} + +func seedRunnerSwitchTask(t *testing.T, repo *Repository, taskID, workspaceID string, opts seedRunnerSwitchTaskOpts) { + t.Helper() + now := time.Now().UTC() + metadata := opts.Metadata + if metadata == "" { + metadata = "{}" + } + var archivedAt interface{} + if opts.Archived { + archivedAt = now + } + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO tasks (id, workspace_id, parent_id, title, metadata, archived_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), + taskID, workspaceID, opts.ParentID, "runner switch task", metadata, archivedAt, now, now); err != nil { + t.Fatalf("seed task: %v", err) + } +} + +func seedRunnerSwitchRepository(t *testing.T, repo *Repository, repositoryID, workspaceID string) { + t.Helper() + now := time.Now().UTC() + if _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO repositories (id, workspace_id, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`), + repositoryID, workspaceID, repositoryID, now, now); err != nil { + t.Fatalf("seed repository: %v", err) + } +} + +func seedRunnerSwitchTaskRepository(t *testing.T, repo *Repository, taskID, repositoryID string) *models.TaskRepository { + t.Helper() + taskRepo := &models.TaskRepository{ + ID: "task-repo-" + taskID + "-" + repositoryID, + TaskID: taskID, + RepositoryID: repositoryID, + BaseBranch: "main", + } + if err := repo.CreateTaskRepository(context.Background(), taskRepo); err != nil { + t.Fatalf("seed task repository: %v", err) + } + stored, err := repo.GetTaskRepository(context.Background(), taskRepo.ID) + if err != nil { + t.Fatalf("reload task repository: %v", err) + } + return stored +} + +func TestCreateTaskSessionRejectsStaleTaskRunnerResolution(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + if err := repo.SetTaskMetadataKey(ctx, "task-1", models.MetaKeyExecutorProfileID, "profile-new"); err != nil { + t.Fatalf("SetTaskMetadataKey: %v", err) + } + + session := &models.TaskSession{ + ID: "session-stale-runner", + TaskID: "task-1", + AgentProfileID: "agent-1", + ExecutorProfileID: "profile-old", + TaskRunnerResolvedFromTask: true, + TaskRunnerProfileAtResolution: "profile-old", + State: models.TaskSessionStateCreated, + } + err := repo.CreateTaskSession(ctx, session) + if !errors.Is(err, models.ErrTaskRunnerChanged) { + t.Fatalf("CreateTaskSession error = %v, want ErrTaskRunnerChanged", err) + } + if _, err := repo.GetTaskSession(ctx, session.ID); err == nil { + t.Fatal("stale session was persisted") + } +} + +// baseRunnerSwitchRequest builds an eligible-shaped request for taskID +// switching to targetProfileID, with the compatibility gate reporting a +// found clone URL against repoSnapshot — the shape most tests start from +// before overriding the field(s) under test. +func baseRunnerSwitchRequest(taskID, targetProfileID string, repoSnapshot *models.TaskRepository) models.RunnerSwitchRequest { + req := models.RunnerSwitchRequest{ + TaskID: taskID, + ExecutorProfileID: targetProfileID, + } + if repoSnapshot != nil { + req.CompatibilityChecked = true + req.CompatibilityCloneURLFound = true + req.ResolvedRepositoryID = repoSnapshot.RepositoryID + req.ResolvedRepositoryUpdatedAt = repoSnapshot.UpdatedAt + } + return req +} + +func TestSwitchTaskRunner_EligibleTopLevelSingleRepoWritesNewProfile(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + result, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + if err != nil { + t.Fatalf("SwitchTaskRunner error = %v, want nil", err) + } + if !result.Changed { + t.Fatalf("result.Changed = false, want true") + } + if got := result.Task.Metadata[models.MetaKeyExecutorProfileID]; got != "profile-new" { + t.Fatalf("result.Task metadata executor_profile_id = %v, want profile-new", got) + } + + reloaded, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if got := reloaded.Metadata[models.MetaKeyExecutorProfileID]; got != "profile-new" { + t.Fatalf("persisted executor_profile_id = %v, want profile-new", got) + } +} + +// TestSwitchTaskRunner_ResultUpdatedAtMatchesPersistedValue pins +// AC-TASKS-RUNNER-SWITCH-002.19: the event and the response the caller +// receives must carry the task's updated-at value from the committing +// transaction, not an independently-sampled approximation of it. +func TestSwitchTaskRunner_ResultUpdatedAtMatchesPersistedValue(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + result, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + if err != nil { + t.Fatalf("SwitchTaskRunner error = %v, want nil", err) + } + + reloaded, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if !result.Task.UpdatedAt.Equal(reloaded.UpdatedAt) { + t.Fatalf("result.Task.UpdatedAt = %v, want the persisted value %v", result.Task.UpdatedAt, reloaded.UpdatedAt) + } +} + +// TestSwitchTaskRunner_UnrelatedMetadataSurvivesMerge pins AC-002.4: the +// switch writes executor_profile_id through a single-key JSON merge, so +// every other key already stored on the task's metadata column must come +// back unchanged, not be dropped by a whole-object overwrite. +func TestSwitchTaskRunner_UnrelatedMetadataSurvivesMerge(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old","custom_key":"custom_value","nested":{"a":1}}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + result, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + if err != nil { + t.Fatalf("SwitchTaskRunner error = %v, want nil", err) + } + if got := result.Task.Metadata["custom_key"]; got != "custom_value" { + t.Fatalf("result.Task metadata custom_key = %v, want custom_value", got) + } + nested, ok := result.Task.Metadata["nested"].(map[string]interface{}) + if !ok || nested["a"] != float64(1) { + t.Fatalf("result.Task metadata nested = %v, want map with a=1", result.Task.Metadata["nested"]) + } + + reloaded, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if got := reloaded.Metadata["custom_key"]; got != "custom_value" { + t.Fatalf("persisted custom_key = %v, want custom_value", got) + } + if got := reloaded.Metadata[models.MetaKeyExecutorProfileID]; got != "profile-new" { + t.Fatalf("persisted executor_profile_id = %v, want profile-new", got) + } +} + +func TestSwitchTaskRunner_NoOpWhenAlreadyStoredProfile(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-same"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + before, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + + result, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-same", taskRepo)) + if err != nil { + t.Fatalf("SwitchTaskRunner error = %v, want nil", err) + } + if result.Changed { + t.Fatalf("result.Changed = true, want false for a no-op switch") + } + + after, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if !after.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("task updated_at changed on a no-op switch: before=%v after=%v", before.UpdatedAt, after.UpdatedAt) + } +} + +func TestSwitchTaskRunner_RejectsWhenArchived(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{Archived: true}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonTaskArchived) +} + +func TestSwitchTaskRunner_RejectsWhenNoRepository(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", nil)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonNoRepository) +} + +func TestSwitchTaskRunner_RejectsWhenMultipleRepositories(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + seedRunnerSwitchRepository(t, repo, "repo-2", "ws-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-2") + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", nil)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonMultipleRepositories) +} + +func TestSwitchTaskRunner_RejectsWhenSessionExists(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("seed session: %v", err) + } + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonSessionExists) +} + +func TestSwitchTaskRunner_RejectsWhenEnvironmentExists(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env-1", TaskID: "task-1", ExecutorType: "worktree", + WorkspacePath: "/tmp/task-1", Status: models.TaskEnvironmentStatusCreating, + }); err != nil { + t.Fatalf("seed environment: %v", err) + } + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonEnvironmentExists) +} + +func TestSwitchTaskRunner_RejectsWhenExecutorRunningExists(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + SessionID: "session-1", TaskID: "task-1", ExecutorID: "executor-1", Status: "starting", + }); err != nil { + t.Fatalf("seed executor running: %v", err) + } + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonExecutorRunning) +} + +func TestSwitchTaskRunner_RejectsWhenWorkspaceFolderAttached(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + if err := repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{ + TaskID: "task-1", + Sources: []models.WorkspaceSource{ + {Folder: &models.TaskWorkspaceFolder{LocalPath: "/tmp/folder", DisplayName: "folder"}}, + }, + }); err != nil { + t.Fatalf("seed workspace folder: %v", err) + } + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonWorkspaceFolderAttached) +} + +func TestSwitchTaskRunner_RejectsWhenWorkspacePathSet(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"workspace_path":"/tmp/materialized"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonWorkspacePathSet) +} + +func TestSwitchTaskRunner_RejectsWhenGroupMember(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := baseRunnerSwitchRequest("task-1", "profile-new", taskRepo) + req.GroupMembershipChecker = func(ctx context.Context, taskID string) (bool, error) { + return true, nil + } + + _, err := repo.SwitchTaskRunner(ctx, req) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonWorkspaceGroupMember) +} + +func TestSwitchTaskRunner_RejectsWhenSubtaskWithoutNewWorkspace(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "parent-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ParentID: "parent-1"}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + _, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonWorkspaceBindingNotIndependent) +} + +func TestSwitchTaskRunner_AllowsSubtaskWithExplicitNewWorkspace(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "parent-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + ParentID: "parent-1", + Metadata: `{"workspace":{"mode":"new_workspace"}}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + result, err := repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + if err != nil { + t.Fatalf("SwitchTaskRunner error = %v, want nil", err) + } + if !result.Changed { + t.Fatalf("result.Changed = false, want true") + } +} + +func TestSwitchTaskRunner_NotFoundForMissingTask(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + + _, err := repo.SwitchTaskRunner(ctx, models.RunnerSwitchRequest{ + TaskID: "does-not-exist", ExecutorProfileID: "profile-new", + }) + if !errors.Is(err, ErrTaskNotFound) { + t.Fatalf("SwitchTaskRunner error = %v, want ErrTaskNotFound", err) + } +} + +func TestSwitchTaskRunner_CompatibilityConflictWhenCloneURLNotFound(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := baseRunnerSwitchRequest("task-1", "profile-new", taskRepo) + req.CompatibilityCloneURLFound = false + + _, err := repo.SwitchTaskRunner(ctx, req) + if !errors.Is(err, repoerrors.ErrRunnerCompatibilityConflict) { + t.Fatalf("SwitchTaskRunner error = %v, want ErrRunnerCompatibilityConflict", err) + } +} + +func TestSwitchTaskRunner_EvaluationUnavailableWhenRepositoryLinkChangedSinceResolution(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := baseRunnerSwitchRequest("task-1", "profile-new", taskRepo) + // Simulate the pre-transaction compatibility resolution having run + // against a repository link snapshot that is no longer current. + req.ResolvedRepositoryUpdatedAt = taskRepo.UpdatedAt.Add(-time.Hour) + + _, err := repo.SwitchTaskRunner(ctx, req) + if !errors.Is(err, repoerrors.ErrRunnerEvaluationUnavailable) { + t.Fatalf("SwitchTaskRunner error = %v, want ErrRunnerEvaluationUnavailable", err) + } +} + +// TestSwitchTaskRunner_EvaluationUnavailableWhenCompatibilityBecameApplicableSinceResolution +// covers the shape-changed-since-resolution race: the pre-transaction +// resolution skipped the compatibility check because the task did not have +// exactly one repository attachment at that moment, but the target executor +// does require a clone URL and the task now has exactly one repository +// attached (the only shape the mutability gate ever lets this codepath +// reach). The skip cannot be trusted retroactively, so the switch must +// reject as retriable rather than silently apply without ever validating +// compatibility against the now-attached repository. +func TestSwitchTaskRunner_EvaluationUnavailableWhenCompatibilityBecameApplicableSinceResolution(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := models.RunnerSwitchRequest{ + TaskID: "task-1", + ExecutorProfileID: "profile-new", + CompatibilityApplicable: true, + CompatibilityChecked: false, + } + + _, err := repo.SwitchTaskRunner(ctx, req) + if !errors.Is(err, repoerrors.ErrRunnerEvaluationUnavailable) { + t.Fatalf("SwitchTaskRunner error = %v, want ErrRunnerEvaluationUnavailable", err) + } +} + +// TestSwitchTaskRunner_SkipsCompatibilityRecheckWhenGateInapplicable is the +// control for the test above: when the target executor never requires a +// clone URL, CompatibilityApplicable is false and the switch must proceed +// normally even though the task has exactly one repository attached. +func TestSwitchTaskRunner_SkipsCompatibilityRecheckWhenGateInapplicable(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := models.RunnerSwitchRequest{ + TaskID: "task-1", + ExecutorProfileID: "profile-new", + CompatibilityApplicable: false, + CompatibilityChecked: false, + } + + result, err := repo.SwitchTaskRunner(ctx, req) + if err != nil { + t.Fatalf("SwitchTaskRunner = %v, want success", err) + } + if !result.Changed { + t.Fatal("SwitchTaskRunner Changed = false, want true") + } +} + +// TestSwitchTaskRunner_EvaluationUnavailableWhenCompatibilityResolutionFailed +// covers a compatibility-gate resolution that errored or timed out +// pre-transaction (a git subprocess failure, for example) rather than being +// skipped for a shape reason: with the mutability gate otherwise passing, +// the switch must still reject as retriable. +func TestSwitchTaskRunner_EvaluationUnavailableWhenCompatibilityResolutionFailed(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + req := models.RunnerSwitchRequest{ + TaskID: "task-1", + ExecutorProfileID: "profile-new", + CompatibilityApplicable: true, + CompatibilityResolutionFailed: true, + } + + _, err := repo.SwitchTaskRunner(ctx, req) + if !errors.Is(err, repoerrors.ErrRunnerEvaluationUnavailable) { + t.Fatalf("SwitchTaskRunner error = %v, want ErrRunnerEvaluationUnavailable", err) + } +} + +// TestSwitchTaskRunner_MutabilityConflictOutranksCompatibilityResolutionFailure +// pins AC-TASKS-RUNNER-SWITCH-002.17's outcome precedence: when a task is +// both mutability-ineligible (session exists) and its compatibility-gate +// resolution failed, the stable mutability reason must be reported, not the +// retriable evaluation_unavailable one. Reversing this order would tell a +// caller with a genuinely stable conflict to just retry. +func TestSwitchTaskRunner_MutabilityConflictOutranksCompatibilityResolutionFailure(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("seed session: %v", err) + } + + req := models.RunnerSwitchRequest{ + TaskID: "task-1", + ExecutorProfileID: "profile-new", + CompatibilityApplicable: true, + CompatibilityResolutionFailed: true, + } + + _, err := repo.SwitchTaskRunner(ctx, req) + assertRunnerMutabilityConflict(t, err, models.RunnerReasonSessionExists) +} + +func assertRunnerMutabilityConflict(t *testing.T, err error, wantReason string) { + t.Helper() + var conflict *repoerrors.ErrRunnerMutabilityConflict + if !errors.As(err, &conflict) { + t.Fatalf("SwitchTaskRunner error = %v, want *ErrRunnerMutabilityConflict", err) + } + if conflict.Reason != wantReason { + t.Fatalf("conflict reason = %q, want %q", conflict.Reason, wantReason) + } +} + +// TestSwitchTaskRunner_ConcurrentExecutorRunningWriteNeverBlendsWithSwitch is +// the AC-TASKS-RUNNER-SWITCH-002.3a/3b acceptance evidence for the +// UpsertExecutorRunning retrofit: a runner switch racing an executors_running +// write must land as one of exactly two outcomes — the switch commits first +// and the running row is then created under the new profile, or the running +// row lands first and the switch is rejected as executor_running — never a +// switch that both commits and coexists with a running row it never saw. +func TestSwitchTaskRunner_ConcurrentExecutorRunningWriteNeverBlendsWithSwitch(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + var wg sync.WaitGroup + var switchErr, runningErr error + wg.Add(2) + go func() { + defer wg.Done() + _, switchErr = repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + }() + go func() { + defer wg.Done() + runningErr = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + SessionID: "session-race", TaskID: "task-1", ExecutorID: "executor-1", Status: "starting", + }) + }() + wg.Wait() + + if runningErr != nil { + t.Fatalf("UpsertExecutorRunning error = %v, want nil", runningErr) + } + + switchRejectedAsExecutorRunning := false + if switchErr != nil { + var conflict *repoerrors.ErrRunnerMutabilityConflict + if !errors.As(switchErr, &conflict) || conflict.Reason != models.RunnerReasonExecutorRunning { + t.Fatalf("switch error = %v, want nil or ErrRunnerMutabilityConflict{executor_running}", switchErr) + } + switchRejectedAsExecutorRunning = true + } + + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + + if switchRejectedAsExecutorRunning { + if stored != "profile-old" { + t.Fatalf("switch was rejected but stored profile = %q, want unchanged profile-old", stored) + } + return + } + if stored != "profile-new" { + t.Fatalf("switch committed but stored profile = %q, want profile-new", stored) + } +} + +// TestSwitchTaskRunner_ConcurrentWorkspaceFolderAttachNeverBlendsWithSwitch is +// the AC-TASKS-RUNNER-SWITCH-002.3a/3b acceptance evidence for the +// guardWorkspaceSourceParentTx retrofit's no-parent (top-level task) branch: +// a runner switch racing a workspace-folder attachment on the same top-level +// task must land as one of exactly two outcomes, never a switch that commits +// with a folder attached it never saw. +func TestSwitchTaskRunner_ConcurrentWorkspaceFolderAttachNeverBlendsWithSwitch(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + var wg sync.WaitGroup + var switchErr, attachErr error + wg.Add(2) + go func() { + defer wg.Done() + _, switchErr = repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + }() + go func() { + defer wg.Done() + attachErr = repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{ + TaskID: "task-1", + Sources: []models.WorkspaceSource{ + {Folder: &models.TaskWorkspaceFolder{LocalPath: "/tmp/race-folder", DisplayName: "race-folder"}}, + }, + }) + }() + wg.Wait() + + if attachErr != nil { + t.Fatalf("CreateWorkspaceSourceBatch error = %v, want nil", attachErr) + } + + switchRejectedAsWorkspaceFolderAttached := false + if switchErr != nil { + var conflict *repoerrors.ErrRunnerMutabilityConflict + if !errors.As(switchErr, &conflict) || conflict.Reason != models.RunnerReasonWorkspaceFolderAttached { + t.Fatalf("switch error = %v, want nil or ErrRunnerMutabilityConflict{workspace_folder_attached}", switchErr) + } + switchRejectedAsWorkspaceFolderAttached = true + } + + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + + if switchRejectedAsWorkspaceFolderAttached { + if stored != "profile-old" { + t.Fatalf("switch was rejected but stored profile = %q, want unchanged profile-old", stored) + } + return + } + if stored != "profile-new" { + t.Fatalf("switch committed but stored profile = %q, want profile-new", stored) + } +} + +// TestSwitchTaskRunner_ConcurrentTaskRepositoryReparentNeverBlendsWithSwitch +// is the AC-TASKS-RUNNER-SWITCH-002.3a/3b acceptance evidence for the +// UpdateTaskRepository retrofit: re-parenting the task's sole repository +// link away to another task races a runner switch on the vacated task. The +// two outcomes are the switch committing first (against the still-attached +// repository) or the re-parent landing first and the switch being rejected +// as no_repository — never a switch that commits after its task lost its +// only repository. +func TestSwitchTaskRunner_ConcurrentTaskRepositoryReparentNeverBlendsWithSwitch(t *testing.T) { + repo := newRunnerSwitchTestRepo(t) + ctx := context.Background() + seedRunnerSwitchWorkspace(t, repo, "ws-1") + seedRunnerSwitchTask(t, repo, "task-1", "ws-1", seedRunnerSwitchTaskOpts{ + Metadata: `{"executor_profile_id":"profile-old"}`, + }) + seedRunnerSwitchTask(t, repo, "task-2", "ws-1", seedRunnerSwitchTaskOpts{}) + seedRunnerSwitchRepository(t, repo, "repo-1", "ws-1") + taskRepo := seedRunnerSwitchTaskRepository(t, repo, "task-1", "repo-1") + + reparented := *taskRepo + reparented.TaskID = "task-2" + + var wg sync.WaitGroup + var switchErr, reparentErr error + wg.Add(2) + go func() { + defer wg.Done() + _, switchErr = repo.SwitchTaskRunner(ctx, baseRunnerSwitchRequest("task-1", "profile-new", taskRepo)) + }() + go func() { + defer wg.Done() + reparentErr = repo.UpdateTaskRepository(ctx, &reparented) + }() + wg.Wait() + + if reparentErr != nil { + t.Fatalf("UpdateTaskRepository error = %v, want nil", reparentErr) + } + + switchRejectedAsNoRepository := false + if switchErr != nil { + var conflict *repoerrors.ErrRunnerMutabilityConflict + if !errors.As(switchErr, &conflict) || conflict.Reason != models.RunnerReasonNoRepository { + t.Fatalf("switch error = %v, want nil or ErrRunnerMutabilityConflict{no_repository}", switchErr) + } + switchRejectedAsNoRepository = true + } + + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + stored, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + + if switchRejectedAsNoRepository { + if stored != "profile-old" { + t.Fatalf("switch was rejected but stored profile = %q, want unchanged profile-old", stored) + } + return + } + if stored != "profile-new" { + t.Fatalf("switch committed but stored profile = %q, want profile-new", stored) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index b9ebc932e4..850ec659f3 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1033,6 +1033,9 @@ func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExec session.State = models.TaskSessionStateCreated } if tx, ok := exec.(*sqlx.Tx); ok { + if err := r.verifyTaskRunnerResolutionTx(ctx, tx, session); err != nil { + return err + } if session.TaskEnvironmentID != "" { if err := recoveryclaim.EnsureAvailableTx(ctx, r.db, tx, session.TaskEnvironmentID); err != nil { return err @@ -1099,6 +1102,37 @@ func (r *Repository) createTaskSession(ctx context.Context, exec taskSessionExec return err } +// verifyTaskRunnerResolutionTx rejects a session built from a task snapshot +// that is older than the task-row lock it now holds. A runner switch and +// session creation therefore have one total order: the switch commits first +// and this check asks the caller to retry with the new runner, or this +// transaction inserts the session first and the switch is rejected by its +// mutability gate. +func (r *Repository) verifyTaskRunnerResolutionTx(ctx context.Context, tx *sqlx.Tx, session *models.TaskSession) error { + if session == nil || !session.TaskRunnerResolvedFromTask { + return nil + } + var metadataJSON string + err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT metadata FROM tasks WHERE id = ?`), session.TaskID).Scan(&metadataJSON) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: task %s no longer exists", models.ErrTaskRunnerChanged, session.TaskID) + } + if err != nil { + return fmt.Errorf("verify task runner resolution: %w", err) + } + metadata := make(map[string]interface{}) + if strings.TrimSpace(metadataJSON) != "" && strings.TrimSpace(metadataJSON) != "{}" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return fmt.Errorf("verify task runner resolution metadata: %w", err) + } + } + currentProfileID, _ := metadata[models.MetaKeyExecutorProfileID].(string) + if currentProfileID != session.TaskRunnerProfileAtResolution || currentProfileID != session.ExecutorProfileID { + return models.ErrTaskRunnerChanged + } + return nil +} + func (r *Repository) persistWorkflowSessionRouteTx( ctx context.Context, exec taskSessionExecutor, @@ -1121,6 +1155,16 @@ func (r *Repository) persistWorkflowSessionRouteTx( return r.setTaskMetadataKeyWithExecutor(ctx, exec, taskID, models.MetaKeyWorkflowSessionRoute, prepared, time.Now().UTC()) } +// setTaskMetadataKeyWithExecutor is SetTaskMetadataKey's tx-capable sibling, +// following the same shape as removeTaskMetadataKeyWithExecutor: every +// existing single-key metadata writer executes on the shared handle and +// none accepts a transaction, but a caller inside a serialized transaction +// (workflow session route persistence, the runner switch) needs its write to +// land only if that transaction commits. updatedAt is supplied by the +// caller, rather than sampled here, so the value written to the row and the +// value the caller carries forward (into a returned task or event) are the +// same instant rather than two independent clock reads either side of the +// write. func (r *Repository) setTaskMetadataKeyWithExecutor( ctx context.Context, exec taskSessionExecutor, diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index 5736845bd8..cd463884d4 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -171,6 +171,14 @@ func (r *Repository) GetTaskEnvironmentByTaskID(ctx context.Context, taskID stri 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 { diff --git a/apps/backend/internal/task/repository/sqlite/task_repository.go b/apps/backend/internal/task/repository/sqlite/task_repository.go index 7ca97f4a38..f02385193e 100644 --- a/apps/backend/internal/task/repository/sqlite/task_repository.go +++ b/apps/backend/internal/task/repository/sqlite/task_repository.go @@ -4,12 +4,16 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" + "sort" "strings" "time" "github.com/google/uuid" + "github.com/jmoiron/sqlx" + kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" ) @@ -142,7 +146,16 @@ func (r *Repository) ListTaskRepositoryProviders(ctx context.Context, taskID str return providers, err } -// UpdateTaskRepository updates an existing task-repository link +// UpdateTaskRepository updates an existing task-repository link in place. +// +// Takes the shared task-row lock on both the link's current and target task +// before writing: this is the only writer that can change a task's +// repository count without +// insert/delete, so a concurrent runner switch's repository-count read must +// resolve fully before or fully after this update, on whichever task ID(s) +// it touches. Locking both (sorted, to avoid a lock-order deadlock between +// two concurrent re-parents) covers a re-parent moving the link away from +// its current task as well as an in-place field update. func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { taskRepo.UpdatedAt = time.Now().UTC() @@ -151,7 +164,24 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models. metadataJSON = []byte("{}") } - result, err := r.db.ExecContext(ctx, r.db.Rebind(` + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + currentTaskID, err := r.currentTaskRepositoryTaskIDTx(ctx, tx, taskRepo.ID) + if err != nil { + return err + } + for _, taskID := range lockOrderedTaskIDs(currentTaskID, taskRepo.TaskID) { + if lockErr := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), taskID); lockErr != nil && + !errors.Is(lockErr, kandevdb.ErrTaskRowNotFound) { + return lockErr + } + } + + result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE task_repositories SET task_id = ?, repository_id = ?, base_branch = ?, checkout_branch = ?, branch_policy_id = ?, branch_policy_name = ?, branch_policy_base_branch = ?, branch_policy_branch_template = ?, branch_policy_pull_request_target = ?, @@ -168,7 +198,39 @@ func (r *Repository) UpdateTaskRepository(ctx context.Context, taskRepo *models. if rows == 0 { return fmt.Errorf("task repository not found: %s", taskRepo.ID) } - return nil + return tx.Commit() +} + +// currentTaskRepositoryTaskIDTx returns the task_id a task_repositories row +// currently holds, or "" if the row does not exist (UpdateTaskRepository's +// own not-found check runs after the write and stays authoritative). +func (r *Repository) currentTaskRepositoryTaskIDTx(ctx context.Context, tx *sqlx.Tx, id string) (string, error) { + var taskID string + err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_repositories WHERE id = ?`), id).Scan(&taskID) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return taskID, err +} + +// lockOrderedTaskIDs returns the distinct, non-empty task IDs among a and b +// in a stable sorted order, so two concurrent re-parents always attempt +// their locks in the same relative order and cannot deadlock each other. +func lockOrderedTaskIDs(a, b string) []string { + seen := make(map[string]struct{}, 2) + ids := make([]string, 0, 2) + for _, id := range []string{a, b} { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + sort.Strings(ids) + return ids } // DeleteTaskRepository deletes a task-repository link by ID diff --git a/apps/backend/internal/task/repository/sqlite/task_repository_comparison_target.go b/apps/backend/internal/task/repository/sqlite/task_repository_comparison_target.go index 8860ad4888..439a85d195 100644 --- a/apps/backend/internal/task/repository/sqlite/task_repository_comparison_target.go +++ b/apps/backend/internal/task/repository/sqlite/task_repository_comparison_target.go @@ -10,13 +10,17 @@ import ( "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/securityutil" + kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" ) // UpdateTaskRepositoryComparisonTarget atomically persists a provider-owned // target on the exact task-repository attachment. It never changes the -// attachment's checkout or base branch. +// attachment's checkout or base branch. It mutates the link in place and +// bumps task_repositories.updated_at, so it takes the owning task's row +// lock: a concurrent runner switch's compatibility re-check must resolve +// fully before or fully after this write. func (r *Repository) UpdateTaskRepositoryComparisonTarget( ctx context.Context, id string, @@ -34,7 +38,7 @@ func (r *Repository) UpdateTaskRepositoryComparisonTarget( } defer func() { _ = tx.Rollback() }() - taskRepo, err := r.getTaskRepositoryForUpdate(ctx, tx, id) + taskRepo, err := r.lockTaskThenGetTaskRepository(ctx, tx, id) if err != nil { return nil, false, err } @@ -98,7 +102,10 @@ func applyComparisonTarget( // UpdateTaskRepositoryBaseBranchAndClearComparisonTarget applies a user // selected comparison branch while removing provider-owned target state in -// the same transaction. This also handles selecting the same visible branch. +// the same transaction. This also handles selecting the same visible +// branch. It mutates the link in place and bumps +// task_repositories.updated_at, so it takes the owning task's row lock, +// same reason as UpdateTaskRepositoryComparisonTarget above. func (r *Repository) UpdateTaskRepositoryBaseBranchAndClearComparisonTarget( ctx context.Context, id string, @@ -113,7 +120,7 @@ func (r *Repository) UpdateTaskRepositoryBaseBranchAndClearComparisonTarget( } defer func() { _ = tx.Rollback() }() - taskRepo, err := r.getTaskRepositoryForUpdate(ctx, tx, id) + taskRepo, err := r.lockTaskThenGetTaskRepository(ctx, tx, id) if err != nil { return nil, false, err } @@ -136,6 +143,34 @@ func (r *Repository) UpdateTaskRepositoryBaseBranchAndClearComparisonTarget( return taskRepo, true, nil } +// lockTaskThenGetTaskRepository keeps the task-scoped lock order consistent +// with UpdateTaskRepository: task row first, task-repository link second. +// The initial task_id read is unlocked only to identify which task row to +// lock. The link is re-read under FOR UPDATE afterwards and the owner is +// checked again so a concurrent re-parent cannot update through a stale owner. +func (r *Repository) lockTaskThenGetTaskRepository(ctx context.Context, tx *sqlx.Tx, id string) (*models.TaskRepository, error) { + var taskID string + err := tx.QueryRowContext(ctx, r.db.Rebind(`SELECT task_id FROM task_repositories WHERE id = ?`), id).Scan(&taskID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("task repository not found: %s", id) + } + if err != nil { + return nil, err + } + if lockErr := kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), taskID); lockErr != nil && + !errors.Is(lockErr, kandevdb.ErrTaskRowNotFound) { + return nil, lockErr + } + taskRepo, err := r.getTaskRepositoryForUpdate(ctx, tx, id) + if err != nil { + return nil, err + } + if taskRepo.TaskID != taskID { + return nil, fmt.Errorf("task repository %s moved from task %s to task %s during update", id, taskID, taskRepo.TaskID) + } + return taskRepo, nil +} + func (r *Repository) getTaskRepositoryForUpdate(ctx context.Context, tx *sqlx.Tx, id string) (*models.TaskRepository, error) { query := ` SELECT id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at diff --git a/apps/backend/internal/task/repository/sqlite/workspace_folder.go b/apps/backend/internal/task/repository/sqlite/workspace_folder.go index f9c33002cc..bdcd3121e1 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_folder.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_folder.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" + kandevdb "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/repoerrors" ) @@ -114,9 +115,16 @@ func (r *Repository) createWorkspaceSourceBatchTx(ctx context.Context, tx *sqlx. // of the durable batch transaction. The no-op update takes the target task row // lock on PostgreSQL and SQLite's writer lock, so a reparent either commits // before this predicate (and is rejected) or after the source batch commits. +// +// A top-level task (ExpectedParentID == "") has no parent relation to +// validate, but the batch still needs the same row lock: without it, a +// folder attachment here and a concurrent runner switch's mutability read +// could each proceed unaware of the other, letting the switch commit +// against a task it had already judged eligible before the attachment +// landed. func (r *Repository) guardWorkspaceSourceParentTx(ctx context.Context, tx *sqlx.Tx, batch *models.WorkspaceSourceBatch) error { if batch.ExpectedParentID == "" { - return nil + return kandevdb.LockTaskRowInTx(ctx, tx, r.db.DriverName(), batch.TaskID) } result, err := tx.ExecContext(ctx, r.db.Rebind(` UPDATE tasks SET updated_at = updated_at diff --git a/apps/backend/internal/task/service/handoff_workspace_test.go b/apps/backend/internal/task/service/handoff_workspace_test.go index 30db82598d..e597d70410 100644 --- a/apps/backend/internal/task/service/handoff_workspace_test.go +++ b/apps/backend/internal/task/service/handoff_workspace_test.go @@ -677,6 +677,10 @@ func (r *phase4TaskRepo) ReleaseTaskExternalID(context.Context, string, string) r.panicNotUsed("ReleaseTaskExternalID") return nil, nil } +func (r *phase4TaskRepo) SwitchTaskRunner(context.Context, models.RunnerSwitchRequest) (*models.RunnerSwitchResult, error) { + r.panicNotUsed("SwitchTaskRunner") + return nil, nil +} func TestWorkspacePolicy_MetadataBlock(t *testing.T) { pol := WorkspacePolicy{ diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index c3ea6dbbc7..4ed68ec015 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -439,6 +439,8 @@ type Service struct { quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat) branchFetcher *branchFetcher envDestroyer EnvironmentDestroyer + wsGroupMembership WorkspaceGroupMembershipReader + executorCapabilityProber ExecutorCapabilityProber sshTaskDirReclaimer SSHTaskDirReclaimer // orphanReapHostSnapshotter and orphanReapVerifier back the reap phase's // host process detection. Nil selects the real platform implementation diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 88b8bd9192..0badec8391 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -437,6 +437,12 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas // already-open clients rather than being pinned by preserveOmittedField. "workspace_orphaned": models.WorkspaceOrphaned(task.Metadata), } + // runner_editable/runner_ineligible_reason mirror the task projection's + // always-present contract: never omitted, so a client merging this event + // never mistakes an absent key for a retained stale value. + runnerView := s.runnerMutabilityEventView(ctx, task) + data["runner_editable"] = runnerView.Editable + data["runner_ineligible_reason"] = runnerView.Reason data["queued_for_step_id"] = task.QueuedForStepID if task.QueuedAt != nil { data["queued_at"] = task.QueuedAt.Format(time.RFC3339Nano) diff --git a/apps/backend/internal/task/service/service_runner_mutability_views_test.go b/apps/backend/internal/task/service/service_runner_mutability_views_test.go new file mode 100644 index 0000000000..b6d4b370c5 --- /dev/null +++ b/apps/backend/internal/task/service/service_runner_mutability_views_test.go @@ -0,0 +1,155 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// fakeWorkspaceGroupMembershipReader is a test double for +// WorkspaceGroupMembershipReader: it reports the fixed set of task IDs as +// active group members regardless of what the real office schema would say. +type fakeWorkspaceGroupMembershipReader struct { + memberTaskIDs map[string]bool +} + +func (f fakeWorkspaceGroupMembershipReader) HasWorkspaceGroupForTask(_ context.Context, taskID string) (bool, error) { + return f.memberTaskIDs[taskID], nil +} + +func (f fakeWorkspaceGroupMembershipReader) GetActiveWorkspaceGroupTaskIDs(_ context.Context, taskIDs []string) (map[string]bool, error) { + out := make(map[string]bool, len(taskIDs)) + for _, id := range taskIDs { + if f.memberTaskIDs[id] { + out[id] = true + } + } + return out, nil +} + +// seedRunnerMutabilityViewsTasks creates one workspace/workflow and seven +// tasks, each isolated to trigger exactly one of BuildRunnerMutabilityViews' +// six batched signals (or none, for the eligible baseline). Returns the +// tasks keyed by scenario name. +func seedRunnerMutabilityViewsTasks(t *testing.T, svc *Service, repo *sqliterepo.Repository) map[string]*models.Task { + t.Helper() + ctx := context.Background() + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-mutability-views", Name: "Mutability Views WS"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-mutability-views", WorkspaceID: "ws-mutability-views", Name: "Board"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-mutability-views", WorkspaceID: "ws-mutability-views", Name: "repo-a", DefaultBranch: "main", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + newTask := func(title string, withRepo bool) *models.Task { + req := &CreateTaskRequest{ + WorkspaceID: "ws-mutability-views", WorkflowID: "wf-mutability-views", WorkflowStepID: "step-1", + Title: title, + } + if withRepo { + req.Repositories = []TaskRepositoryInput{{RepositoryID: "repo-mutability-views", BaseBranch: "main"}} + } + result, err := svc.CreateTask(ctx, req) + if err != nil { + t.Fatalf("CreateTask(%q): %v", title, err) + } + return result.Task + } + + tasks := map[string]*models.Task{ + "eligible": newTask("eligible", true), + "no_repository": newTask("no repository", false), + "session": newTask("session exists", true), + "environment": newTask("environment exists", true), + "executor_running": newTask("executor running", true), + "workspace_folder": newTask("workspace folder attached", true), + "group_member": newTask("workspace group member", true), + } + + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "mv-session-1", TaskID: tasks["session"].ID, State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "mv-env-1", TaskID: tasks["environment"].ID, ExecutorType: "worktree", + WorkspacePath: "/tmp/mv-env-1", Status: models.TaskEnvironmentStatusCreating, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + // executors_running.session_id has no foreign key to task_sessions, so + // this signal can be seeded in isolation without also tripping the + // higher-precedence session_exists check. + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + SessionID: "mv-session-for-exec", TaskID: tasks["executor_running"].ID, + ExecutorID: "mv-executor-1", Status: "starting", + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + if err := repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{ + TaskID: tasks["workspace_folder"].ID, + Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{ + LocalPath: "/tmp/mv-folder", DisplayName: "folder", + }}}, + }); err != nil { + t.Fatalf("CreateWorkspaceSourceBatch: %v", err) + } + + return tasks +} + +// TestBuildRunnerMutabilityViewsSeedsEachSignalIndependently proves that +// BuildRunnerMutabilityViews reads all six of its batched signals — task +// repository count, session existence, environment existence, executor +// running existence, workspace folder existence, and (via a wired +// WorkspaceGroupMembershipReader) active group membership — rather than only +// being exercised incidentally through SwitchTaskRunner's in-transaction +// evaluation, which reads the same six signals through a different code +// path (direct queries, not the batch). +func TestBuildRunnerMutabilityViewsSeedsEachSignalIndependently(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + tasks := seedRunnerMutabilityViewsTasks(t, svc, repo) + + svc.SetWorkspaceGroupMembershipReader(fakeWorkspaceGroupMembershipReader{ + memberTaskIDs: map[string]bool{tasks["group_member"].ID: true}, + }) + + ordered := make([]*models.Task, 0, len(tasks)) + for _, task := range tasks { + ordered = append(ordered, task) + } + views := svc.BuildRunnerMutabilityViews(context.Background(), ordered) + + wantReason := map[string]string{ + "eligible": models.RunnerReasonEligible, + "no_repository": models.RunnerReasonNoRepository, + "session": models.RunnerReasonSessionExists, + "environment": models.RunnerReasonEnvironmentExists, + "executor_running": models.RunnerReasonExecutorRunning, + "workspace_folder": models.RunnerReasonWorkspaceFolderAttached, + "group_member": models.RunnerReasonWorkspaceGroupMember, + } + + for scenario, task := range tasks { + view, ok := views[task.ID] + if !ok { + t.Errorf("%s: missing view for task %s", scenario, task.ID) + continue + } + wantEditable := scenario == "eligible" + if view.Editable != wantEditable { + t.Errorf("%s: Editable = %v, want %v", scenario, view.Editable, wantEditable) + } + if view.Reason != wantReason[scenario] { + t.Errorf("%s: Reason = %q, want %q", scenario, view.Reason, wantReason[scenario]) + } + } +} diff --git a/apps/backend/internal/task/service/service_runner_switch.go b/apps/backend/internal/task/service/service_runner_switch.go new file mode 100644 index 0000000000..de9cc4ec04 --- /dev/null +++ b/apps/backend/internal/task/service/service_runner_switch.go @@ -0,0 +1,398 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/repoclone" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// ErrRunnerSwitchMalformed reports that id or executor_profile_id was blank: +// absent, empty, or whitespace-only. Checked before any lookup. +var ErrRunnerSwitchMalformed = errors.New("id and executor_profile_id are required") + +// ErrExecutorProfileInvalid reports that the named executor profile does not +// exist, or its executor is soft-deleted or not active. +var ErrExecutorProfileInvalid = errors.New("executor profile is invalid or not available") + +// runnerCloneURLResolutionTimeout bounds the local-checkout `git remote +// get-url origin` fallback the compatibility gate runs before opening the +// switch transaction, so a stalled subprocess cannot hold up every +// session-creation path serialized against the same task row lock. +const runnerCloneURLResolutionTimeout = 5 * time.Second + +// RunnerMutabilityView is the derived runner-mutability verdict for one +// task. Mirrors models.RunnerMutabilityVerdict without exposing repository +// batching details to callers. +type RunnerMutabilityView struct { + Editable bool + Reason string +} + +// WorkspaceGroupMembershipReader is the narrow slice of the office +// workspace-group repository the runner-mutability evaluator and switch +// action need: whether a task currently holds an active (non-released) +// group membership. Existence-only, so it never needs office's +// WorkspaceGroup model. Satisfied structurally by *officesqlite.Repository. +type WorkspaceGroupMembershipReader interface { + HasWorkspaceGroupForTask(ctx context.Context, taskID string) (bool, error) + GetActiveWorkspaceGroupTaskIDs(ctx context.Context, taskIDs []string) (map[string]bool, error) +} + +// SetWorkspaceGroupMembershipReader wires the office-owned workspace-group +// membership reads the runner-mutability gate needs (condition 9) but the +// task repository cannot reach directly. Optional: when unset, every task is +// treated as not a group member, matching a workspace with no Office groups. +func (s *Service) SetWorkspaceGroupMembershipReader(r WorkspaceGroupMembershipReader) { + s.wsGroupMembership = r +} + +// ExecutorCapabilityProber answers whether an executor type's runtime +// requires a git clone URL to materialize a workspace. Satisfied directly by +// *lifecycle.Manager. +type ExecutorCapabilityProber interface { + RequiresCloneURL(executorType string) bool +} + +// SetExecutorCapabilityProber wires the executor-type capability probe the +// runner switch's compatibility gate uses. Optional: when unset, the gate +// treats every executor type as not requiring a clone URL, so the switch +// never blocks on it. +func (s *Service) SetExecutorCapabilityProber(p ExecutorCapabilityProber) { + s.executorCapabilityProber = p +} + +// BuildRunnerMutabilityViews computes the runner-mutability verdict for a +// batch of tasks in a bounded number of queries, mirroring +// BuildDependencyViews: one batched read per signal, never a per-task +// fan-out. +// +// A failing signal read degrades every task in the batch to +// evaluation_unavailable: every signal query here covers every task in the +// batch it was given, so a query-level failure has no narrower scope to +// degrade to. +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 + } + ids := make([]string, 0, len(tasks)) + for _, t := range tasks { + if t != nil { + ids = append(ids, t.ID) + } + } + + unavailable := func() map[string]RunnerMutabilityView { + for _, id := range ids { + out[id] = RunnerMutabilityView{Reason: models.RunnerReasonEvaluationUnavailable} + } + return out + } + + // A caller that constructs a Service with only the repositories its own + // path needs (every isolated-service test does this) leaves some of + // these unwired. That is a normal configuration, not a bug, so it + // degrades the same way a failed read does rather than panicking. + if s.taskRepos == nil || s.sessions == nil || s.taskEnvironments == nil || + s.executors == nil || s.workspaceFolders == nil { + return unavailable() + } + + 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 { + if t == nil { + continue + } + out[t.ID] = batch.verdictFor(t) + } + return out +} + +// runnerMutabilitySignalBatch holds every batched signal read +// BuildRunnerMutabilityViews needs, keyed by task ID, so the caller can +// collapse five separate read-or-degrade steps into one error check. +type runnerMutabilitySignalBatch struct { + repoLinks map[string][]*models.TaskRepository + sessionCounts map[string]int + envExists map[string]bool + execExists map[string]bool + folders map[string][]*models.TaskWorkspaceFolder + groupMembers map[string]bool +} + +// loadRunnerMutabilitySignalBatch runs the five (or six, with an office +// reader wired) batched signal reads and stops at the first failure — every +// query here covers the whole of ids, so partial success has no meaningful +// partial result to keep. +func (s *Service) loadRunnerMutabilitySignalBatch(ctx context.Context, ids []string) (runnerMutabilitySignalBatch, error) { + var batch runnerMutabilitySignalBatch + var err error + + batch.repoLinks, err = s.taskRepos.ListTaskRepositoriesByTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + batch.sessionCounts, err = s.sessions.GetSessionCountsByTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + batch.envExists, err = s.taskEnvironments.GetTaskEnvironmentExistenceByTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + batch.execExists, err = s.executors.GetExecutorRunningExistenceByTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + batch.folders, err = s.workspaceFolders.ListTaskWorkspaceFoldersByTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + batch.groupMembers = map[string]bool{} + if s.wsGroupMembership != nil { + batch.groupMembers, err = s.wsGroupMembership.GetActiveWorkspaceGroupTaskIDs(ctx, ids) + if err != nil { + return runnerMutabilitySignalBatch{}, err + } + } + return batch, nil +} + +// verdictFor evaluates one task's mutability verdict against the batch's +// already-loaded signals. +func (b runnerMutabilitySignalBatch) verdictFor(t *models.Task) RunnerMutabilityView { + signals := models.RunnerSignalsFromTask(t) + signals.RepositoryCount = len(b.repoLinks[t.ID]) + signals.HasSession = b.sessionCounts[t.ID] > 0 + signals.HasEnvironment = b.envExists[t.ID] + signals.HasExecutorRunning = b.execExists[t.ID] + signals.HasWorkspaceFolder = len(b.folders[t.ID]) > 0 + signals.HasActiveGroupMembership = b.groupMembers[t.ID] + verdict := models.EvaluateRunnerMutability(signals) + return RunnerMutabilityView{Editable: verdict.Editable, Reason: verdict.Reason} +} + +// runnerMutabilityEventView is the batch-of-one precedent +// dependencyEventFields sets: compute the verdict for a single just-touched +// task so publishTaskEventNow can stamp it unconditionally on every event. +func (s *Service) runnerMutabilityEventView(ctx context.Context, task *models.Task) RunnerMutabilityView { + views := s.BuildRunnerMutabilityViews(ctx, []*models.Task{task}) + return views[task.ID] +} + +// SwitchTaskRunner authorizes the caller, resolves the compatibility gate +// outside any transaction, and applies the switch through the task +// repository's single locked transaction. Outcome precedence, checked in +// order: malformed request, not found, not authorized, target invalid, +// mutability conflict, compatibility conflict, then assignment. +func (s *Service) SwitchTaskRunner(ctx context.Context, taskID, executorProfileID string) (*models.Task, error) { + if strings.TrimSpace(taskID) == "" || strings.TrimSpace(executorProfileID) == "" { + return nil, ErrRunnerSwitchMalformed + } + + // Not-found is checked before authorization, and independently of it: + // authorizeTaskScope only performs its own existence check for a scoped + // (identity-bearing) caller, so an unscoped internal caller would + // otherwise skip the not-found stage entirely. + 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 { + previous, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) + s.logger.Info("task runner switched", + zap.String("task_id", taskID), + zap.String("previous_executor_profile_id", previous), + zap.String("executor_profile_id", executorProfileID)) + s.PublishTaskUpdated(ctx, result.Task) + } + return result.Task, nil +} + +func (s *Service) runnerGroupMembershipChecker(ctx context.Context, taskID string) (bool, error) { + if s.wsGroupMembership == nil { + return false, nil + } + return s.wsGroupMembership.HasWorkspaceGroupForTask(ctx, taskID) +} + +// resolveExecutorForProfile resolves and validates the target executor: it +// is invalid when its profile does not exist or its executor is +// soft-deleted or not active. Both produce the same invalid outcome. +func (s *Service) resolveExecutorForProfile(ctx context.Context, executorProfileID string) (*models.Executor, error) { + profile, err := s.executors.GetExecutorProfile(ctx, executorProfileID) + if err != nil { + if errors.Is(err, repoerrors.ErrExecutorProfileNotFound) { + return nil, ErrExecutorProfileInvalid + } + return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) + } + executor, err := s.executors.GetExecutor(ctx, profile.ExecutorID) + if err != nil { + if errors.Is(err, models.ErrExecutorNotFound) { + return nil, ErrExecutorProfileInvalid + } + return nil, fmt.Errorf("%w: %v", repoerrors.ErrRunnerEvaluationUnavailable, err) + } + if executor.Status != models.ExecutorStatusActive { + return nil, ErrExecutorProfileInvalid + } + return executor, nil +} + +// runnerCompatibilityResolution carries the compatibility gate's +// pre-transaction resolution into the request the repository layer +// re-confirms inside the locked transaction. +type runnerCompatibilityResolution struct { + // applicable is true whenever the target executor type requires a clone + // URL, independent of whether checked below is also true. The repository + // layer uses this to tell "the gate never applied to this executor" from + // "the gate applied but the repository shape did not allow resolution", + // which must be re-validated rather than silently skipped. + applicable bool + checked bool + // resolutionFailed is true when the gate applies but a lookup it needed + // (a repository read, or the clone-URL candidate lookup) errored or timed + // out, rather than being skipped because the repository shape did not + // allow evaluation. Carried into the transaction and reported only after + // the mutability gate has passed, at the same ordered position a + // determinate verdict would report at, so it can never preempt a stable + // mutability conflict. + resolutionFailed bool + cloneURLFound bool + repositoryID string + repositoryUpdatedAt time.Time +} + +// resolveRunnerCompatibility resolves the compatibility gate's verdict: it +// runs entirely outside any transaction, so a subprocess call here never +// holds the task row lock. When the target executor does not require a +// clone URL, resolution is skipped and inapplicable — the mutability gate +// reports the correct code for that shape at its own ordered stage. When the +// task does not have exactly one repository attachment right now, resolution +// is skipped but stays applicable, so the repository layer re-checks the +// shape from inside the transaction rather than trusting a stale skip. A +// candidate lookup that errors or times out is carried forward as a failed +// resolution rather than aborting here: outcome precedence requires the +// mutability gate, re-evaluated inside the transaction, to be checked before +// this failure is reported, so a task that is also ineligible for a stable +// reason (e.g. session_exists) never gets told to retry a lookup failure +// instead. +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} + } + link := links[0] + repo, err := s.repoEntities.GetRepository(ctx, link.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: link.RepositoryID, + repositoryUpdatedAt: link.UpdatedAt, + } +} + +// runnerRepositoryHasCloneURL resolves candidates in the launch path's order +// — a recorded remote URL, then a provider-derived URL, then the local +// checkout's origin remote — but, unlike that path's own helper, distinguishes +// an exhausted candidate list (false, nil) from a failed or timed-out lookup +// (false, non-nil error). Git reporting a negative answer (e.g. "no such +// remote 'origin'") is an exhausted candidate; failing to launch git at all, +// or exceeding the bounded timeout, is a failed lookup. +func runnerRepositoryHasCloneURL(ctx context.Context, repo *models.Repository) (bool, error) { + if strings.TrimSpace(repo.RemoteURL) != "" { + return true, nil + } + if repo.ProviderOwner != "" && repo.ProviderName != "" && + (!strings.EqualFold(repo.Provider, "gitlab") || strings.TrimSpace(repo.ProviderHost) != "") { + cloneURL, err := repoclone.CloneURLWithHost( + repo.Provider, repo.ProviderHost, repo.ProviderOwner, repo.ProviderName, repoclone.ProtocolHTTPS, + ) + if err == nil && strings.TrimSpace(cloneURL) != "" { + return true, nil + } + } + if repo.LocalPath == "" { + return false, nil + } + timeoutCtx, cancel := context.WithTimeout(ctx, runnerCloneURLResolutionTimeout) + defer cancel() + cmd := subproc.NewGitCommand(timeoutCtx, "-C", repo.LocalPath, "remote", "get-url", "origin") + out, err := subproc.RunGitOutputClass(timeoutCtx, subproc.GitLifecycle, cmd) + if err != nil { + if timeoutCtx.Err() != nil { + return false, timeoutCtx.Err() + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, err + } + return strings.TrimSpace(string(out)) != "", nil +} diff --git a/apps/backend/internal/task/service/service_runner_switch_test.go b/apps/backend/internal/task/service/service_runner_switch_test.go new file mode 100644 index 0000000000..9190846b7b --- /dev/null +++ b/apps/backend/internal/task/service/service_runner_switch_test.go @@ -0,0 +1,449 @@ +package service + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/orgunit" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" + workflowrepo "github.com/kandev/kandev/internal/workflow/repository" + "github.com/kandev/kandev/internal/worktree" +) + +// fakeCloneURLProber is a test double for ExecutorCapabilityProber: it +// answers RequiresCloneURL with a fixed value regardless of executor type. +type fakeCloneURLProber struct{ requires bool } + +func (f fakeCloneURLProber) RequiresCloneURL(string) bool { return f.requires } + +// newRunnerSwitchTestService builds a fully-wired *Service on real SQLite +// repositories, including the two dependencies SwitchTaskRunner needs beyond +// createTestService's set: WorkspaceFolders (required for the mutability +// batch to evaluate rather than degrade) and the office-owned workspace-group +// membership reader. Kept in its own file/helper rather than widening the +// shared createTestService, whose many existing callers do not need either. +func newRunnerSwitchTestService(t *testing.T) (*Service, *MockEventBus, *sqliterepo.Repository, *officesqlite.Repository) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + writerConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("failed to open test database (writer): %v", err) + } + readerConn, err := db.OpenSQLiteReader(dbPath) + if err != nil { + t.Fatalf("failed to open test database (reader): %v", err) + } + writerDB := sqlx.NewDb(writerConn, "sqlite3") + readerDB := sqlx.NewDb(readerConn, "sqlite3") + // SwitchTaskRunner's mutability gate calls the office-owned + // GroupMembershipChecker from inside the task repository's open write + // transaction (after the row lock). That read must land on a separate + // connection pool, exactly like production's writer/reader split: a + // single shared handle (as most other tests in this package use) forces + // the nested read to wait for the very transaction it is called from, + // since the writer pool allows only one open connection. + repo, cleanup, err := repository.Provide(writerDB, readerDB, nil) + if err != nil { + t.Fatalf("failed to create test repository: %v", err) + } + if _, err := worktree.NewSQLiteStore(writerDB, readerDB); err != nil { + t.Fatalf("failed to init worktree store: %v", err) + } + officeRepo, err := officesqlite.NewWithDB(writerDB, readerDB, nil) + if err != nil { + t.Fatalf("failed to apply office migrations: %v", err) + } + if _, err := workflowrepo.NewWithDB(writerDB, readerDB, nil); err != nil { + t.Fatalf("failed to initialize workflow repository: %v", err) + } + t.Cleanup(func() { + if err := writerDB.Close(); err != nil { + t.Errorf("failed to close writer pool: %v", err) + } + if err := readerDB.Close(); err != nil { + t.Errorf("failed to close reader pool: %v", err) + } + if err := cleanup(); err != nil { + t.Errorf("failed to close repo: %v", err) + } + }) + eventBus := NewMockEventBus() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewService(Repos{ + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + WorkspaceFolders: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + RepositorySets: repo, + BranchPolicies: repo, + RepositoryCleanup: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, + ResourceCleanups: repo, + Usage: repo, + }, eventBus, log, RepositoryDiscoveryConfig{}) + svc.SetWorkspaceBootstrapper(repo) + svc.SetWorkspaceGroupMembershipReader(officeRepo) + unitStore, unitErr := orgunit.NewStore(db.NewPool(writerDB, readerDB)) + if unitErr != nil { + t.Fatalf("failed to init unit store: %v", unitErr) + } + unitSvc := orgunit.NewService(unitStore, nil) + unitSvc.SetWorkspaceCounter(repo) + svc.SetUnitPlacer(unitSvc) + svc.SetUnitReach(unitSvc) + testUnits[t.Name()] = unitSvc + t.Cleanup(func() { delete(testUnits, t.Name()) }) + svc.SetWorkflowStepGetter(&testWorkflowStepGetter{repo: repo}) + if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil { + t.Fatalf("failed to start task resource cleanup worker: %v", err) + } + t.Cleanup(svc.StopTaskResourceCleanupWorker) + return svc, eventBus, repo, officeRepo +} + +// seedRunnerSwitchTask creates a workspace, workflow/step, one repository and +// one task attached to it, returning the created task. Each call site uses +// its own fresh newRunnerSwitchTestService database, so the fixed IDs below +// never collide across tests. +func seedRunnerSwitchTask(t *testing.T, svc *Service, repo *sqliterepo.Repository) *models.Task { + t.Helper() + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-runner", Name: "Runner WS"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-runner", WorkspaceID: "ws-runner", Name: "Board"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-runner-a", WorkspaceID: "ws-runner", Name: "repo-a", DefaultBranch: "main", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + result, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-runner", WorkflowID: "wf-runner", WorkflowStepID: "step-1", + Title: "Runner switch task", + Repositories: []TaskRepositoryInput{{RepositoryID: "repo-runner-a", BaseBranch: "main"}}, + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + return result.Task +} + +// seedRunnerSwitchExecutor creates an executor and a profile pointing at it, +// returning the profile ID. +func seedRunnerSwitchExecutor(t *testing.T, repo *sqliterepo.Repository, executorID, profileID string, status models.ExecutorStatus) { + t.Helper() + ctx := context.Background() + if err := repo.CreateExecutor(ctx, &models.Executor{ + ID: executorID, Name: executorID, Type: models.ExecutorTypeLocal, Status: status, + }); err != nil { + t.Fatalf("CreateExecutor: %v", err) + } + if err := repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{ + ID: profileID, ExecutorID: executorID, Name: profileID, + }); err != nil { + t.Fatalf("CreateExecutorProfile: %v", err) + } +} + +func TestSwitchTaskRunner_MalformedRequestRejectedBeforeAnyLookup(t *testing.T) { + svc, _, _, _ := newRunnerSwitchTestService(t) + ctx := context.Background() + + cases := []struct { + name string + taskID string + profile string + }{ + {"empty id", "", "profile-1"}, + {"whitespace id", " ", "profile-1"}, + {"empty profile", "task-1", ""}, + {"whitespace profile", "task-1", "\t\n"}, + {"both empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := svc.SwitchTaskRunner(ctx, tc.taskID, tc.profile); !errors.Is(err, ErrRunnerSwitchMalformed) { + t.Fatalf("SwitchTaskRunner(%q, %q) = %v, want ErrRunnerSwitchMalformed", tc.taskID, tc.profile, err) + } + }) + } +} + +// TestSwitchTaskRunner_TaskNotFoundEvenForUnscopedCaller pins the deliberate +// GetTask-first ordering: authorizeTaskScope alone would skip its own +// existence check for an unscoped (internal) caller, so without the explicit +// pre-check a not-found task would fall through to the target/mutability +// stages instead of failing at stage 2. +func TestSwitchTaskRunner_TaskNotFoundEvenForUnscopedCaller(t *testing.T) { + svc, _, _, _ := newRunnerSwitchTestService(t) + ctx := context.Background() + + _, err := svc.SwitchTaskRunner(ctx, "task-does-not-exist", "profile-does-not-exist-either") + if !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("SwitchTaskRunner on missing task = %v, want ErrTaskNotFound", err) + } +} + +func TestSwitchTaskRunner_ForbiddenForViewerRole(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + seedTeamWorkspace(t, repo, true) + seedUnitViewer(t, "user-carla") + + viewer := ctxAsRole("user-carla", authn.RoleMember) + _, err := svc.SwitchTaskRunner(viewer, "task-team", "any-profile") + if !IsForbidden(err) { + t.Fatalf("SwitchTaskRunner as viewer = %v, want ErrForbidden", err) + } +} + +func TestSwitchTaskRunner_TargetProfileMissing(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + + _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-does-not-exist") + if !errors.Is(err, ErrExecutorProfileInvalid) { + t.Fatalf("SwitchTaskRunner with missing profile = %v, want ErrExecutorProfileInvalid", err) + } +} + +func TestSwitchTaskRunner_TargetExecutorNotActive(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-disabled", "profile-disabled", models.ExecutorStatusDisabled) + + _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-disabled") + if !errors.Is(err, ErrExecutorProfileInvalid) { + t.Fatalf("SwitchTaskRunner with disabled executor = %v, want ErrExecutorProfileInvalid", err) + } +} + +func TestSwitchTaskRunner_MutabilityGateBlocksWhenSessionExists(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active", "profile-active", models.ExecutorStatusActive) + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "sess-runner-1", TaskID: task.ID, State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-active") + var conflict *repoerrors.ErrRunnerMutabilityConflict + if !errors.As(err, &conflict) { + t.Fatalf("SwitchTaskRunner with an active session = %v, want ErrRunnerMutabilityConflict", err) + } + if conflict.Reason != models.RunnerReasonSessionExists { + t.Fatalf("conflict reason = %q, want %q", conflict.Reason, models.RunnerReasonSessionExists) + } +} + +func TestSwitchTaskRunner_CompatibilityGateBlocksWhenNoCloneURLResolves(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + svc.SetExecutorCapabilityProber(fakeCloneURLProber{requires: true}) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-remote", "profile-remote", models.ExecutorStatusActive) + + _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-remote") + if !errors.Is(err, repoerrors.ErrRunnerCompatibilityConflict) { + t.Fatalf("SwitchTaskRunner with no resolvable clone URL = %v, want ErrRunnerCompatibilityConflict", err) + } +} + +func TestSwitchTaskRunner_NoOpSuccessWhenAlreadyAssigned(t *testing.T) { + svc, eventBus, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active-2", "profile-already-set", models.ExecutorStatusActive) + if err := repo.SetTaskMetadataKey(context.Background(), task.ID, models.MetaKeyExecutorProfileID, "profile-already-set"); err != nil { + t.Fatalf("SetTaskMetadataKey: %v", err) + } + eventBus.ClearEvents() + + updated, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-already-set") + if err != nil { + t.Fatalf("SwitchTaskRunner no-op = %v, want success", err) + } + if got, _ := updated.Metadata[models.MetaKeyExecutorProfileID].(string); got != "profile-already-set" { + t.Fatalf("metadata executor_profile_id = %q, want unchanged", got) + } + if eventBusHasType(eventBus, events.TaskUpdated) { + t.Fatal("no-op switch published task.updated, want none") + } +} + +func TestSwitchTaskRunner_SuccessAssignsAndPublishesEvent(t *testing.T) { + svc, eventBus, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active-3", "profile-target", models.ExecutorStatusActive) + eventBus.ClearEvents() + + updated, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-target") + if err != nil { + t.Fatalf("SwitchTaskRunner = %v, want success", err) + } + if got, _ := updated.Metadata[models.MetaKeyExecutorProfileID].(string); got != "profile-target" { + t.Fatalf("metadata executor_profile_id = %q, want %q", got, "profile-target") + } + if !eventBusHasType(eventBus, events.TaskUpdated) { + t.Fatal("successful switch did not publish task.updated") + } + + reloaded, err := repo.GetTask(context.Background(), task.ID) + if err != nil { + t.Fatalf("GetTask after switch: %v", err) + } + if got, _ := reloaded.Metadata[models.MetaKeyExecutorProfileID].(string); got != "profile-target" { + t.Fatalf("persisted executor_profile_id = %q, want %q", got, "profile-target") + } +} + +// TestSwitchTaskRunner_NoSessionEnvironmentOrExecutorSideEffects pins +// AC-003.2: a runner switch changes only the task's stored +// executor_profile_id metadata. It must not create a session, an +// environment, an executors_running row, or a workspace folder as a side +// effect of the write. +func TestSwitchTaskRunner_NoSessionEnvironmentOrExecutorSideEffects(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active-4", "profile-target-2", models.ExecutorStatusActive) + + if _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-target-2"); err != nil { + t.Fatalf("SwitchTaskRunner = %v, want success", err) + } + + sessions, err := repo.ListTaskSessions(context.Background(), task.ID) + if err != nil { + t.Fatalf("ListTaskSessions: %v", err) + } + if len(sessions) != 0 { + t.Fatalf("sessions after switch = %d, want 0", len(sessions)) + } + + env, err := repo.GetTaskEnvironmentByTaskID(context.Background(), task.ID) + if err != nil { + t.Fatalf("GetTaskEnvironmentByTaskID: %v", err) + } + if env != nil { + t.Fatalf("task environment after switch = %+v, want none", env) + } + + running, err := repo.ListExecutorsRunningByTaskID(context.Background(), task.ID) + if err != nil { + t.Fatalf("ListExecutorsRunningByTaskID: %v", err) + } + if len(running) != 0 { + t.Fatalf("executors_running after switch = %d, want 0", len(running)) + } + + folders, err := repo.ListTaskWorkspaceFolders(context.Background(), task.ID) + if err != nil { + t.Fatalf("ListTaskWorkspaceFolders: %v", err) + } + if len(folders) != 0 { + t.Fatalf("workspace folders after switch = %d, want 0", len(folders)) + } +} + +// TestSwitchTaskRunner_EventPayloadCarriesRunnerMutabilityFields pins +// AC-003.3: the published task.updated event's payload must carry the +// task's real runner_editable/runner_ineligible_reason values, not merely +// exist as an event of the right type. +func TestSwitchTaskRunner_EventPayloadCarriesRunnerMutabilityFields(t *testing.T) { + svc, eventBus, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active-5", "profile-target-3", models.ExecutorStatusActive) + eventBus.ClearEvents() + + if _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-target-3"); err != nil { + t.Fatalf("SwitchTaskRunner = %v, want success", err) + } + + var payload map[string]interface{} + for _, evt := range eventBus.GetPublishedEvents() { + if evt.Type != events.TaskUpdated { + continue + } + data, ok := evt.Data.(map[string]interface{}) + if !ok { + t.Fatalf("task.updated event Data = %T, want map[string]interface{}", evt.Data) + } + payload = data + } + if payload == nil { + t.Fatal("no task.updated event published") + } + editable, ok := payload["runner_editable"].(bool) + if !ok || !editable { + t.Fatalf("runner_editable = %v, want true", payload["runner_editable"]) + } + if reason, _ := payload["runner_ineligible_reason"].(string); reason != models.RunnerReasonEligible { + t.Fatalf("runner_ineligible_reason = %q, want %q", reason, models.RunnerReasonEligible) + } +} + +// TestReplaceTaskRepositoriesBypassesRunnerMutabilityGate pins F19's accepted +// risk: replaceTaskRepositories (reached through UpdateTask's Repositories +// field) is a third writer of task_repositories that does not consult the +// runner-mutability gate the way SwitchTaskRunner does. The same task that +// SwitchTaskRunner refuses to touch because a session already exists still +// accepts a repository replacement through UpdateTask. This is deliberately +// NOT a bug fix target — the assertion exists so a future change to either +// path is a conscious decision, not a silent behavior drift. +func TestReplaceTaskRepositoriesBypassesRunnerMutabilityGate(t *testing.T) { + svc, _, repo, _ := newRunnerSwitchTestService(t) + task := seedRunnerSwitchTask(t, svc, repo) + seedRunnerSwitchExecutor(t, repo, "executor-active-4", "profile-f19", models.ExecutorStatusActive) + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "sess-runner-f19", TaskID: task.ID, State: models.TaskSessionStateCreated, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + // Confirm the task is genuinely runner-immutable right now. + if _, err := svc.SwitchTaskRunner(context.Background(), task.ID, "profile-f19"); err == nil { + t.Fatal("expected SwitchTaskRunner to be blocked by the session, got success") + } + + if err := repo.CreateRepository(context.Background(), &models.Repository{ + ID: "repo-runner-b", WorkspaceID: "ws-runner", Name: "repo-b", DefaultBranch: "main", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + setBranchUpdateWorkflowStep(svc) + if _, err := svc.UpdateTask(context.Background(), task.ID, &UpdateTaskRequest{ + Repositories: []TaskRepositoryInput{{RepositoryID: "repo-runner-b", BaseBranch: "main"}}, + }); err != nil { + t.Fatalf("UpdateTask replacing repositories on a materialized task = %v, want success (F19 pin)", err) + } + + rows, err := repo.ListTaskRepositories(context.Background(), task.ID) + if err != nil { + t.Fatalf("ListTaskRepositories: %v", err) + } + if len(rows) != 1 || rows[0].RepositoryID != "repo-runner-b" { + t.Fatalf("task repositories = %#v, want only repo-runner-b (replacement went through unguarded)", rows) + } +} diff --git a/apps/backend/internal/task/service/service_task_environments_test.go b/apps/backend/internal/task/service/service_task_environments_test.go index cd004b45b3..ef789f5473 100644 --- a/apps/backend/internal/task/service/service_task_environments_test.go +++ b/apps/backend/internal/task/service/service_task_environments_test.go @@ -41,6 +41,9 @@ func (s *stubEnvRepo) GetTaskEnvironment(context.Context, string) (*models.TaskE func (s *stubEnvRepo) GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error) { return s.env, s.getErr } +func (s *stubEnvRepo) GetTaskEnvironmentExistenceByTaskIDs(context.Context, []string) (map[string]bool, error) { + return map[string]bool{}, nil +} func (s *stubEnvRepo) UpdateTaskEnvironment(context.Context, *models.TaskEnvironment) error { return nil } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index a4bada5775..f6849d1a1b 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -81,6 +81,7 @@ const ( ActionTaskMove = "task.move" ActionTaskState = "task.state" ActionTaskArchive = "task.archive" + ActionTaskRunner = "task.runner" ActionTaskPlanCreate = "task.plan.create" ActionTaskPlanGet = "task.plan.get" ActionTaskPlanUpdate = "task.plan.update" diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index a7f283416c..881880c675 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -553,6 +553,9 @@ function KanbanBoardDialogs({ state: editingTask.state as BackendTask["state"], repositoryId: editingTask.repositoryId, repositories: editingTask.repositories, + primaryExecutorProfileId: editingTask.primaryExecutorProfileId ?? undefined, + runnerEditable: editingTask.runnerEditable, + runnerIneligibleReason: editingTask.runnerIneligibleReason, } : null } diff --git a/apps/web/components/kanban-card-types.ts b/apps/web/components/kanban-card-types.ts new file mode 100644 index 0000000000..12c89ee727 --- /dev/null +++ b/apps/web/components/kanban-card-types.ts @@ -0,0 +1,118 @@ +import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types"; +import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; +import { + type ForegroundActivity, + type TaskPendingAction, + type TaskPriority, + type TaskState, +} from "@/lib/types/http"; +import type { PluginTaskMenuContext } from "@/lib/plugins/types"; + +export interface Task { + id: string; + title: string; + workflowStepId: string; + state?: TaskState; + priority?: TaskPriority; + description?: string; + position?: number; + repositoryId?: string; + /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ + repositories?: Array<{ + id: string; + repository_id: string; + base_branch?: string; + checkout_branch?: string; + branch_policy_id?: string; + branch_policy_name?: string; + branch_policy_base_branch?: string; + branch_policy_branch_template?: string; + branch_policy_pull_request_target?: string; + position: number; + }>; + sessionCount?: number | null; + primarySessionId?: string | null; + /** + * Primary session's runtime state. Decoupled from `state` (the workflow + * column). Used to suppress the running-spinner when the agent has already + * finished — the workflow may leave the task in IN_PROGRESS for review. + */ + primarySessionState?: string | null; + primarySessionPendingAction?: TaskPendingAction | null; + taskPendingAction?: TaskPendingAction | null; + /** + * Task-level MOST-ACTIVE-WINS activity aggregate; + * undefined/null when no session is running. Drives the background-running + * affordance on the card status icon. + */ + foregroundActivity?: ForegroundActivity | null; + /** True when the task's session was mid-turn when the backend died. */ + interrupted?: boolean; + /** True when a workflow step's auto_start_agent on_enter action failed to + * launch a run for this task. */ + autoStartFailed?: boolean; + /** + * True when the task is waiting on the operator to notice, not on the + * operator to act — a settled session with a positively-sampled + * background process still live (spec: + * docs/specs/disambiguate-waiting/spec.md). Outranked by pending-input + * and any live foregroundActivity. The revision/epoch fields backing the + * stale-update discard rule live on the store's KanbanState Task shape + * (kanban/types.ts) and the wire payload, not here — this board-rendering + * type only needs the resolved boolean. + */ + parkedOnBackgroundWork?: boolean; + /** True when this task inherits an archived parent's workspace and can no + * longer materialize or start. */ + workspaceOrphaned?: boolean; + /** Live subagents summed across this task's sessions; drives the count chip. */ + activeSubagentCount?: number; + reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; + primaryExecutorId?: string | null; + primaryExecutorProfileId?: string | null; + primaryExecutorType?: string | null; + primaryExecutorName?: string | null; + isRemoteExecutor?: boolean; + /** Whether the executor profile can be switched right now. Never gap-filled on merge. */ + runnerEditable?: boolean; + /** Machine-readable reason for `runnerEditable`. Never gap-filled on merge. */ + runnerIneligibleReason?: string; + /** Human assignee (user id); the card renders their name read-only. */ + assigneeUserId?: string; + parentTaskId?: string | null; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; + updatedAt?: string; + createdAt?: string; + wipAdmitted?: boolean; + queuedForStepId?: string; + queuedForStepTitle?: string; + /** Derived dependency state — see TaskDependencyRef in the kanban slice. */ + blocked?: boolean; + blockedReason?: string; + dependsOn?: TaskDependencyRef[]; + blocks?: TaskDependencyRef[]; + startWhenUnblocked?: boolean; + queuedAt?: string; + issueUrl?: string; + issueNumber?: number; + statusSummary?: TaskStatusSummary | null; +} + +export type RepositoryChip = { + label: string; + path?: string; +}; + +export interface WorkflowStep { + id: string; + title: string; + color: string; + events?: { + on_enter?: Array<{ type: string; config?: Record }>; + on_turn_start?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + on_exit?: Array<{ type: string; config?: Record }>; + }; +} + +export type KanbanPresentation = PluginTaskMenuContext["presentation"]; diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 7098296770..7b815d93d9 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -15,120 +15,10 @@ import { useAppStore } from "@/components/state-provider"; import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation"; import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog"; import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; -import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types"; -import type { TaskStatusSummary } from "@/lib/types/task-status-summary"; import type { TaskActionOptions } from "@/hooks/use-task-actions"; -import { - type ForegroundActivity, - type TaskPendingAction, - type TaskPriority, - type TaskState, -} from "@/lib/types/http"; -import type { PluginTaskMenuContext } from "@/lib/plugins/types"; +import type { Task, RepositoryChip, WorkflowStep, KanbanPresentation } from "./kanban-card-types"; -export interface Task { - id: string; - title: string; - workflowStepId: string; - state?: TaskState; - priority?: TaskPriority; - description?: string; - position?: number; - repositoryId?: string; - /** All repositories linked to the task; used to render a "+N" chip for multi-repo. */ - repositories?: Array<{ - id: string; - repository_id: string; - base_branch?: string; - checkout_branch?: string; - branch_policy_id?: string; - branch_policy_name?: string; - branch_policy_base_branch?: string; - branch_policy_branch_template?: string; - branch_policy_pull_request_target?: string; - position: number; - }>; - sessionCount?: number | null; - primarySessionId?: string | null; - /** - * Primary session's runtime state. Decoupled from `state` (the workflow - * column). Used to suppress the running-spinner when the agent has already - * finished — the workflow may leave the task in IN_PROGRESS for review. - */ - primarySessionState?: string | null; - primarySessionPendingAction?: TaskPendingAction | null; - taskPendingAction?: TaskPendingAction | null; - /** - * Task-level MOST-ACTIVE-WINS activity aggregate; - * undefined/null when no session is running. Drives the background-running - * affordance on the card status icon. - */ - foregroundActivity?: ForegroundActivity | null; - /** True when the task's session was mid-turn when the backend died. */ - interrupted?: boolean; - /** True when a workflow step's auto_start_agent on_enter action failed to - * launch a run for this task. */ - autoStartFailed?: boolean; - /** - * True when the task is waiting on the operator to notice, not on the - * operator to act — a settled session with a positively-sampled - * background process still live (spec: - * docs/specs/disambiguate-waiting/spec.md). Outranked by pending-input - * and any live foregroundActivity. The revision/epoch fields backing the - * stale-update discard rule live on the store's KanbanState Task shape - * (kanban/types.ts) and the wire payload, not here — this board-rendering - * type only needs the resolved boolean. - */ - parkedOnBackgroundWork?: boolean; - /** True when this task inherits an archived parent's workspace and can no - * longer materialize or start. */ - workspaceOrphaned?: boolean; - /** Live subagents summed across this task's sessions; drives the count chip. */ - activeSubagentCount?: number; - reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; - primaryExecutorId?: string | null; - primaryExecutorType?: string | null; - primaryExecutorName?: string | null; - isRemoteExecutor?: boolean; - /** Human assignee (user id); the card renders their name read-only. */ - assigneeUserId?: string; - parentTaskId?: string | null; - workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; - updatedAt?: string; - createdAt?: string; - wipAdmitted?: boolean; - queuedForStepId?: string; - queuedForStepTitle?: string; - /** Derived dependency state — see TaskDependencyRef in the kanban slice. */ - blocked?: boolean; - blockedReason?: string; - dependsOn?: TaskDependencyRef[]; - blocks?: TaskDependencyRef[]; - startWhenUnblocked?: boolean; - queuedAt?: string; - issueUrl?: string; - issueNumber?: number; - statusSummary?: TaskStatusSummary | null; -} - -export type RepositoryChip = { - label: string; - path?: string; -}; - -export interface WorkflowStep { - id: string; - title: string; - color: string; - events?: { - on_enter?: Array<{ type: string; config?: Record }>; - on_turn_start?: Array<{ type: string; config?: Record }>; - on_turn_complete?: Array<{ type: string; config?: Record }>; - on_exit?: Array<{ type: string; config?: Record }>; - }; -} - -export type KanbanPresentation = PluginTaskMenuContext["presentation"]; +export type { Task, RepositoryChip, WorkflowStep, KanbanPresentation }; export interface KanbanCardProps { task: Task; diff --git a/apps/web/components/task-create-dialog-create-mode-selectors.tsx b/apps/web/components/task-create-dialog-create-mode-selectors.tsx index fe3ee74297..25a29bfe5a 100644 --- a/apps/web/components/task-create-dialog-create-mode-selectors.tsx +++ b/apps/web/components/task-create-dialog-create-mode-selectors.tsx @@ -26,6 +26,8 @@ type CreateModeSelectorsProps = { selectedAgentProfileName: string | null; effectiveWorkflowName: string | null; executorProfileName: string | null; + runnerEditable: boolean; + runnerIneligibleReason: string; }; /** @@ -52,6 +54,8 @@ export function CreateModeSelectors(props: CreateModeSelectorsProps) { selectedAgentProfileName={props.selectedAgentProfileName} effectiveWorkflowName={props.effectiveWorkflowName} executorProfileName={props.executorProfileName} + runnerEditable={props.runnerEditable} + runnerIneligibleReason={props.runnerIneligibleReason} AgentSelectorComponent={AgentSelector} ExecutorProfileSelectorComponent={ExecutorProfileSelector} /> diff --git a/apps/web/components/task-create-dialog-effects-executor.test.ts b/apps/web/components/task-create-dialog-effects-executor.test.ts index 0f0c8e557f..a77d7cde7d 100644 --- a/apps/web/components/task-create-dialog-effects-executor.test.ts +++ b/apps/web/components/task-create-dialog-effects-executor.test.ts @@ -20,7 +20,7 @@ type DefaultSelFake = Pick< | "executorProfileId" | "setAgentProfileId" | "setExecutorId" - | "setExecutorProfileId" + | "setExecutorProfileIdFromSeed" | "noRepository" | "preferLocalExecutor" | "repositories" @@ -37,7 +37,7 @@ function makeDefaultSelFs(overrides: Partial = {}): DialogFormSt executorProfileId: "profile-1", setAgentProfileId: vi.fn(), setExecutorId: vi.fn(), - setExecutorProfileId: vi.fn(), + setExecutorProfileIdFromSeed: vi.fn(), noRepository: false, preferLocalExecutor: false, repositories: [], @@ -98,8 +98,10 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE)); - expect(fs.setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_LOCAL); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_WORKTREE), + ); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalledWith(PROFILE_LOCAL); await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(worktree.id)); expect(fs.setExecutorId).not.toHaveBeenCalledWith(local.id); }); @@ -113,7 +115,9 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(worktree.id)); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_WORKTREE), + ); }); it("defaults repo-less tasks to a local profile because worktree needs a repo", async () => { @@ -129,9 +133,13 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id)); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL), + ); }); +}); +describe("useDefaultSelectionsEffect - executor profile defaults for explicit local-path tasks", () => { it("defaults explicit local-path tasks to a local profile when no profile was saved", async () => { const fs = makeDefaultSelFs({ executorId: "", @@ -149,7 +157,9 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id)); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL), + ); }); it("ignores a workspace-default worktree executor for explicit local-path tasks", async () => { @@ -171,7 +181,9 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id)); expect(fs.setExecutorId).not.toHaveBeenCalledWith(worktree.id); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL), + ); }); it("does not fall back to a worktree profile for explicit local-path tasks", async () => { @@ -187,8 +199,50 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_DOCKER)); - expect(fs.setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_WORKTREE); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_DOCKER), + ); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalledWith(PROFILE_WORKTREE); + }); +}); + +// AC-TASKS-RUNNER-SWITCH-004.5a: a task with a stored executor profile seeds +// the picker from that stored value, not from the create-mode "resolve a +// default" autopick. +describe("useDefaultSelectionsEffect - editing task's stored executor profile", () => { + it("seeds the stored profile instead of resolving a create-mode default", async () => { + const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "" }); + const local = localExecutor(); + const worktree = worktreeExecutor(); + const sel = makeSel({ executors: [local, worktree] }); + + renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [], PROFILE_LOCAL)); + + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL), + ); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalledWith(PROFILE_WORKTREE); + }); + + it("does not reseed once the picker already carries a value", async () => { + const fs = makeDefaultSelFs({ executorId: "exec-local", executorProfileId: PROFILE_WORKTREE }); + const sel = makeSel({ executors: [worktreeExecutor()] }); + + renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [], PROFILE_LOCAL)); + + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalled(); + }); + + it("falls back to the create-mode default when the task stores no profile", async () => { + const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "" }); + const worktree = worktreeExecutor(); + const sel = makeSel({ executors: [worktree] }); + + renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [], null)); + + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_WORKTREE), + ); }); }); @@ -207,7 +261,7 @@ describe("useDefaultSelectionsEffect - executor profile restoration", () => { await new Promise((resolve) => setTimeout(resolve, 10)); expect(fs.setExecutorId).not.toHaveBeenCalled(); - expect(fs.setExecutorProfileId).not.toHaveBeenCalled(); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalled(); const selAfter = makeSel({ executors: [worktree], @@ -215,7 +269,9 @@ describe("useDefaultSelectionsEffect - executor profile restoration", () => { }); rerender({ sel: selAfter }); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_WORKTREE), + ); }); }); @@ -231,7 +287,9 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_WORKTREE_B), + ); }); it("keeps an explicit local workspace default ahead of a saved worktree profile", async () => { @@ -245,7 +303,7 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( }); const setExecutorId = vi.fn(); - const setExecutorProfileId = vi.fn(); + const setExecutorProfileIdFromSeed = vi.fn(); const { rerender } = renderHook( ({ formState }) => useDefaultSelectionsEffect(formState, true, sel, []), { @@ -254,20 +312,20 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( executorProfileId: "", executorId: "", setExecutorId, - setExecutorProfileId, + setExecutorProfileIdFromSeed, }), }, }, ); - await waitFor(() => expect(setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); - expect(setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_WORKTREE_B); + await waitFor(() => expect(setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL)); + expect(setExecutorProfileIdFromSeed).not.toHaveBeenCalledWith(PROFILE_WORKTREE_B); rerender({ formState: makeDefaultSelFs({ executorProfileId: PROFILE_LOCAL, executorId: "", setExecutorId, - setExecutorProfileId, + setExecutorProfileIdFromSeed, }), }); @@ -297,7 +355,9 @@ describe("useDefaultSelectionsEffect - launch preferences", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(local.id)); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); + await waitFor(() => + expect(fs.setExecutorProfileIdFromSeed).toHaveBeenCalledWith(PROFILE_LOCAL), + ); }); }); @@ -317,7 +377,7 @@ describe("useDefaultSelectionsEffect - multi-repo executor selection", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(fs.setExecutorProfileId).not.toHaveBeenCalled(); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalled(); }); it("leaves a worktree profile alone when 2+ Remote rows are set", async () => { @@ -335,7 +395,7 @@ describe("useDefaultSelectionsEffect - multi-repo executor selection", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(fs.setExecutorProfileId).not.toHaveBeenCalled(); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalled(); }); it("does not swap when only a single Remote row is filled", async () => { @@ -353,6 +413,6 @@ describe("useDefaultSelectionsEffect - multi-repo executor selection", () => { renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(fs.setExecutorProfileId).not.toHaveBeenCalled(); + expect(fs.setExecutorProfileIdFromSeed).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index e47b3c6336..5f8bdc6226 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -456,13 +456,14 @@ export function useDefaultSelectionsEffect( open: boolean, sel: StoreSelections, workflows: Array<{ id: string; agent_profile_id?: string }>, + editingTaskExecutorProfileId?: string | null, ) { const { executors, workspaceDefaults } = sel; const { executorId, executorProfileId, setExecutorId, - setExecutorProfileId, + setExecutorProfileIdFromSeed, noRepository, preferLocalExecutor: presetPrefersLocalExecutor, useRemote, @@ -490,6 +491,19 @@ export function useDefaultSelectionsEffect( preferLocalExecutor, ], ); + const hasStoredEditingExecutorProfile = Boolean(editingTaskExecutorProfileId); + // Seed the picker from the task's own stored runner (edit mode only) + // before any create-mode "resolve a default" autopick runs: a task that + // already has a stored runner is seeded from that stored value, not from + // the create-mode default-resolution path. Gating the autopick effect's + // own `open` (below) rather than adding a state-machine flag keeps this + // race-free: the two effects cannot both compute a pick for the same + // render. + useEffect(() => { + if (!open || executorProfileId || !editingTaskExecutorProfileId) return; + setExecutorProfileIdFromSeed(editingTaskExecutorProfileId); + }, [open, executorProfileId, editingTaskExecutorProfileId, setExecutorProfileIdFromSeed]); + useAgentProfileAutopickEffect(fs, open, sel, workflows); useExecutorIdAutopickEffect({ open, @@ -498,10 +512,10 @@ export function useDefaultSelectionsEffect( setExecutorId, }); useExecutorProfileAutopickEffect({ - open, + open: open && !hasStoredEditingExecutorProfile, executorProfileId, context: executorAutopickContext, - setExecutorProfileId, + setExecutorProfileId: setExecutorProfileIdFromSeed, }); // Derive executorId from the selected executor profile @@ -560,8 +574,15 @@ export function useGitHubUrlErrorEffect(fs: DialogFormState, open: boolean) { } export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) { - const { open, workspaceId, workflowId, effectiveWorkflowId, repositories, repositoriesLoading } = - args; + const { + open, + workspaceId, + workflowId, + effectiveWorkflowId, + repositories, + repositoriesLoading, + editingTaskExecutorProfileId, + } = args; const { agentProfiles, compatibleAgentProfiles, @@ -601,6 +622,7 @@ export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreate effectiveWorkflowId, }, workflows, + editingTaskExecutorProfileId, ); useGitHubUrlErrorEffect(fs, open); } diff --git a/apps/web/components/task-create-dialog-form-body.test.tsx b/apps/web/components/task-create-dialog-form-body.test.tsx index 28f2f75459..567f4819d6 100644 --- a/apps/web/components/task-create-dialog-form-body.test.tsx +++ b/apps/web/components/task-create-dialog-form-body.test.tsx @@ -147,6 +147,8 @@ function makeFs(): DialogFormState { setExecutorId: () => {}, executorProfileId: "", setExecutorProfileId: () => {}, + setExecutorProfileIdFromSeed: () => {}, + seededExecutorProfileId: null, discoveredRepositories: [], setDiscoveredRepositories: () => {}, discoverReposLoading: false, @@ -321,36 +323,43 @@ describe("DialogPromptSection (CLI-mode parity)", () => { }); }); +const SELECTOR_TEST_ID = "agent-selector-stub"; +const AgentSelectorStub = () => ( + +); +const ExecutorSelectorStub = () => ; +const createEditSelectorsBaseProps: Omit< + ComponentProps, + "agentCompatState" +> = { + isTaskStarted: false, + agentProfiles: [{ id: "agent-1", label: "Codex", agent_name: "codex" } as never], + agentProfilesLoading: false, + agentProfileOptions: [], + agentProfileId: "", + onAgentProfileChange: () => {}, + isCreatingSession: false, + executorProfileOptions: [], + executorProfileId: "exec-profile-1", + onExecutorProfileChange: () => {}, + executorsLoading: false, + AgentSelectorComponent: AgentSelectorStub, + ExecutorProfileSelectorComponent: ExecutorSelectorStub, + workflowAgentLocked: false, + executorProfileName: "Docker", + selectedAgentProfileName: null, + effectiveWorkflowName: null, + runnerEditable: true, + runnerIneligibleReason: "eligible", +}; + describe("CreateEditSelectors", () => { const WORKFLOW_NAME = "Development"; const EXECUTOR_NAME = "Fly"; const EMPTY_STATE_TEST_ID = "agent-profile-empty-state"; - const SELECTOR_TEST_ID = "agent-selector-stub"; - const AgentSelectorStub = () => ( - - ); - const ExecutorSelectorStub = () => ; - const baseProps: Omit, "agentCompatState"> = { - isTaskStarted: false, - agentProfiles: [{ id: "agent-1", label: "Codex", agent_name: "codex" } as never], - agentProfilesLoading: false, - agentProfileOptions: [], - agentProfileId: "", - onAgentProfileChange: () => {}, - isCreatingSession: false, - executorProfileOptions: [], - executorProfileId: "exec-profile-1", - onExecutorProfileChange: () => {}, - executorsLoading: false, - AgentSelectorComponent: AgentSelectorStub, - ExecutorProfileSelectorComponent: ExecutorSelectorStub, - workflowAgentLocked: false, - executorProfileName: "Docker", - selectedAgentProfileName: null, - effectiveWorkflowName: null, - }; + const baseProps = createEditSelectorsBaseProps; // @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.4 it("links credential setup to the selected executor profile", () => { @@ -431,3 +440,91 @@ describe("CreateEditSelectors", () => { ); }); }); + +// REQ-TASKS-RUNNER-SWITCH-004: runner-editability gating is independent of +// the agent-compatibility rendering exercised above, split into its own +// block to keep each describe's setup focused. +describe("CreateEditSelectors — runner editability (REQ-TASKS-RUNNER-SWITCH-004)", () => { + const RUNNER_NOTE_TEST_ID = "runner-ineligible-note"; + const baseProps = createEditSelectorsBaseProps; + + // AC-TASKS-RUNNER-SWITCH-004.3: runner editability is independent of + // isTaskStarted — the previous state-only gate must no longer govern it. + it("shows the executor selector for a started task that is still runner-editable", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "executor" })).toBeTruthy(); + expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(RUNNER_NOTE_TEST_ID)).toBeNull(); + }); + + // AC-TASKS-RUNNER-SWITCH-004.2 + it("presents the projected reason instead of the selector when not runner-editable", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "executor" })).toBeNull(); + expect(screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent).toBeTruthy(); + }); + + // AC-TASKS-RUNNER-SWITCH-004.4b: never an empty reason or a raw code — an + // unrecognized reason renders the same copy as evaluation_unavailable. + it("falls back to the evaluation-unavailable copy for an unrecognized reason code", () => { + render( + , + ); + const unrecognized = screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent; + cleanup(); + + render( + , + ); + const known = screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent; + + expect(unrecognized).toBeTruthy(); + expect(unrecognized).toBe(known); + }); + + // AC-TASKS-RUNNER-SWITCH-004.2: the reason is presented unconditionally + // whenever runner_editable is false — a started task is not a carve-out. + // session_exists is itself one of the ineligibility reasons, so this is + // the common shape for any task with a primary session, not an edge case. + it("shows the ineligible reason for a started, runner-ineligible task instead of rendering nothing", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "executor" })).toBeNull(); + expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull(); + expect(screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index a0725dcf18..055b20f72a 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -17,6 +17,7 @@ import { PromptResultRecovery } from "@/components/prompt-result-recovery"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview"; +import { RUNNER_INELIGIBLE_REASON_KEYS } from "@/components/task-create-dialog-helpers"; import { useTranslation } from "react-i18next"; type SelectorOption = { @@ -64,6 +65,10 @@ type CreateEditSelectorsProps = { selectedAgentProfileName: string | null; effectiveWorkflowName: string | null; executorProfileName: string | null; + /** Gates the executor-profile column independently of isTaskStarted. */ + runnerEditable: boolean; + /** Presented instead of the selector when runnerEditable is false. */ + runnerIneligibleReason: string; }; type AgentColumnProps = Pick< @@ -264,36 +269,47 @@ function AgentColumn({ ); } +function RunnerIneligibleNote({ reason }: { reason: string }) { + const { t } = useTranslation(); + const key = RUNNER_INELIGIBLE_REASON_KEYS[reason] ?? "task:runnerReasonEvaluationUnavailable"; + return ( +
+ {t(key)} +
+ ); +} + export const CreateEditSelectors = memo(function CreateEditSelectors( props: CreateEditSelectorsProps, ) { const { t } = useTranslation(); - if (props.isTaskStarted) return null; - const { - executorProfileOptions, - executorProfileId, - onExecutorProfileChange, - executorsLoading, - ExecutorProfileSelectorComponent, - } = props; + const showAgentColumn = !props.isTaskStarted; + const { executorProfileOptions, executorProfileId, onExecutorProfileChange, executorsLoading } = + props; + const { ExecutorProfileSelectorComponent, runnerEditable, runnerIneligibleReason } = props; // Branch + repo selection (and the FreshBranchToggle, which is per-task // branch strategy) live in the chip row above the description; this row // carries only agent and executor profile selectors. return (
+
{showAgentColumn && }
- -
-
- + {runnerEditable ? ( + + ) : ( + + )}
); diff --git a/apps/web/components/task-create-dialog-helpers.test.ts b/apps/web/components/task-create-dialog-helpers.test.ts index 2693dc40ab..9b6f50223a 100644 --- a/apps/web/components/task-create-dialog-helpers.test.ts +++ b/apps/web/components/task-create-dialog-helpers.test.ts @@ -3,6 +3,8 @@ import { autoSelectBranch, buildCreateTaskPayload, buildRepositoriesPayload, + computeRunnerEditable, + computeRunnerIneligibleReason, findUnresolvedProviderRemote, shouldShowTaskTitleField, validateCreateInputs, @@ -109,6 +111,39 @@ describe("autoSelectBranch", () => { }); }); +describe("computeRunnerEditable", () => { + it("is always editable in create mode, regardless of any editingTask", () => { + expect(computeRunnerEditable(false)).toBe(true); + expect(computeRunnerEditable(false, { runnerEditable: false })).toBe(true); + }); + + it("defers to the projected verdict in edit mode", () => { + expect(computeRunnerEditable(true, { runnerEditable: true })).toBe(true); + expect(computeRunnerEditable(true, { runnerEditable: false })).toBe(false); + }); + + // AC-TASKS-RUNNER-SWITCH-004.3: never decide editability from workflow + // state — computeRunnerEditable doesn't even accept a `state` field. + it("fails closed when the projection is absent", () => { + expect(computeRunnerEditable(true, {})).toBe(false); + expect(computeRunnerEditable(true, null)).toBe(false); + expect(computeRunnerEditable(true, undefined)).toBe(false); + }); +}); + +describe("computeRunnerIneligibleReason", () => { + it("returns the projected reason", () => { + expect(computeRunnerIneligibleReason({ runnerIneligibleReason: "session_exists" })).toBe( + "session_exists", + ); + }); + + it("falls back to evaluation_unavailable when absent", () => { + expect(computeRunnerIneligibleReason({})).toBe("evaluation_unavailable"); + expect(computeRunnerIneligibleReason(null)).toBe("evaluation_unavailable"); + }); +}); + describe("shouldShowTaskTitleField", () => { it.each([ { diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index c3ad1db00e..0691791399 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -117,6 +117,52 @@ export function computeIsTaskStarted( return editingTask.state !== "TODO" && editingTask.state !== "CREATED"; } +/** + * Whether the executor-profile selector should be offered for editing. + * Create mode always offers it — there is no task yet for a mutability + * verdict to apply to. Edit mode defers entirely to the projected + * `runner_editable`, never to workflow state; an absent projection fails + * closed. + */ +export function computeRunnerEditable( + isEditMode: boolean, + editingTask?: { runnerEditable?: boolean } | null, +): boolean { + if (!isEditMode) return true; + return editingTask?.runnerEditable ?? false; +} + +/** + * Machine-readable reason to present when {@link computeRunnerEditable} + * returns false. Falls back to the same retriable class the backend uses + * when a projection could not be evaluated. + */ +export function computeRunnerIneligibleReason( + editingTask?: { runnerIneligibleReason?: string } | null, +): string { + return editingTask?.runnerIneligibleReason ?? "evaluation_unavailable"; +} + +/** + * Maps a projected `runner_ineligible_reason`, or the matching + * `details.error_code` on a rejected `task.runner` switch, to the i18n key + * presented to the user. Closed vocabulary; a code this dialog doesn't + * recognize (a future reason it predates) falls back to the same retriable + * copy as an evaluation failure rather than an empty message or a raw code. + */ +export const RUNNER_INELIGIBLE_REASON_KEYS: Record = { + task_archived: "task:runnerReasonTaskArchived", + no_repository: "task:runnerReasonNoRepository", + multiple_repositories: "task:runnerReasonMultipleRepositories", + session_exists: "task:runnerReasonSessionExists", + environment_exists: "task:runnerReasonEnvironmentExists", + executor_running: "task:runnerReasonExecutorRunning", + workspace_folder_attached: "task:runnerReasonWorkspaceFolderAttached", + workspace_path_set: "task:runnerReasonWorkspacePathSet", + workspace_group_member: "task:runnerReasonWorkspaceGroupMember", + workspace_binding_not_independent: "task:runnerReasonWorkspaceBindingNotIndependent", +}; + export function shouldShowTaskTitleField( isCreateMode: boolean, isEditMode: boolean, diff --git a/apps/web/components/task-create-dialog-prop-builders.ts b/apps/web/components/task-create-dialog-prop-builders.ts index 992a02b802..4daf97f5a4 100644 --- a/apps/web/components/task-create-dialog-prop-builders.ts +++ b/apps/web/components/task-create-dialog-prop-builders.ts @@ -15,6 +15,10 @@ import { resolveTaskCreateLaunchPreview, type TaskCreateLaunchPreview, } from "@/components/task-create-dialog-launch-preview"; +import { + computeRunnerEditable, + computeRunnerIneligibleReason, +} from "@/components/task-create-dialog-helpers"; export function computeHasAllBranches(fs: DialogFormState): boolean { if (fs.noRepository) return true; @@ -122,6 +126,8 @@ export function buildDialogFormBodyProps( bottomSlot: props.bottomSlot, descriptionPlaceholder: props.descriptionPlaceholder, workflowLocked: props.lockedFields?.workflow, + runnerEditable: computeRunnerEditable(setup.isEditMode, props.editingTask), + runnerIneligibleReason: computeRunnerIneligibleReason(props.editingTask), }; } diff --git a/apps/web/components/task-create-dialog-setup.ts b/apps/web/components/task-create-dialog-setup.ts index 1720abf61e..c4a302c8d3 100644 --- a/apps/web/components/task-create-dialog-setup.ts +++ b/apps/web/components/task-create-dialog-setup.ts @@ -186,6 +186,7 @@ function useSubmitHandlersWiring({ agentProfileId: computed.effectiveAgentProfileId, executorId: fs.executorId, executorProfileId: fs.executorProfileId, + seededExecutorProfileId: fs.seededExecutorProfileId, editingTask, onSuccess, onCreateSession, @@ -287,19 +288,50 @@ function savedBaseSubmitBlockedReasonForRows( ); } +function useRefreshBranchPolicies(fs: DialogFormState) { + const storeApi = useAppStoreApi(); + const setRepositoryBranchPolicies = useAppStore((state) => state.setRepositoryBranchPolicies); + const setRepositoryBranchPoliciesLoading = useAppStore( + (state) => state.setRepositoryBranchPoliciesLoading, + ); + return useCallback(async () => { + const repositoryIds = [ + ...new Set( + fs.repositories + .map((row) => row.repositoryId) + .filter((repositoryId): repositoryId is string => Boolean(repositoryId)), + ), + ]; + await Promise.all( + repositoryIds.map(async (repositoryId) => { + const requestRevision = + storeApi.getState().repositoryBranchPolicies.revisionByRepositoryId[repositoryId] ?? 0; + setRepositoryBranchPoliciesLoading(repositoryId, true); + try { + const response = await listRepositoryBranchPolicies(repositoryId, { cache: "no-store" }); + setRepositoryBranchPolicies( + repositoryId, + response.repository_branch_policies, + requestRevision, + ); + } catch { + // Keep the original task error visible when recovery cannot refresh. + } finally { + setRepositoryBranchPoliciesLoading(repositoryId, false); + } + }), + ); + }, [fs.repositories, setRepositoryBranchPolicies, setRepositoryBranchPoliciesLoading, storeApi]); +} + function useDialogSetupData( props: TaskCreateDialogProps, fs: ReturnType, ) { const { open, workspaceId, workflowId, defaultStepId, initialValues } = props; const { toast } = useToast(); - const storeApi = useAppStoreApi(); const upsertWorkspaceRepository = useAppStore((state) => state.upsertRepository); const repositoryBranches = useAppStore((state) => state.repositoryBranches); - const setRepositoryBranchPolicies = useAppStore((state) => state.setRepositoryBranchPolicies); - const setRepositoryBranchPoliciesLoading = useAppStore( - (state) => state.setRepositoryBranchPoliciesLoading, - ); const data = useTaskCreateDialogData({ open, workspaceId, @@ -341,6 +373,7 @@ function useDialogSetupData( lastUsedExecutorProfileId: taskCreateLastUsed.executorProfileId, lastUsedBranch: taskCreateLastUsed.branch, preserveBranch: initialValues?.checkoutBranch || initialValues?.branch, + editingTaskExecutorProfileId: props.editingTask?.primaryExecutorProfileId, }); useLockedFieldSync(open, workflowId, initialValues, fs, props.lockedFields?.workflow === true); const handlers = useDialogHandlers(fs, repositories, { @@ -352,34 +385,7 @@ function useDialogSetupData( fs.repositories, repositoryBranches, ); - const refreshBranchPolicies = useCallback(async () => { - const repositoryIds = [ - ...new Set( - fs.repositories - .map((row) => row.repositoryId) - .filter((repositoryId): repositoryId is string => Boolean(repositoryId)), - ), - ]; - await Promise.all( - repositoryIds.map(async (repositoryId) => { - const requestRevision = - storeApi.getState().repositoryBranchPolicies.revisionByRepositoryId[repositoryId] ?? 0; - setRepositoryBranchPoliciesLoading(repositoryId, true); - try { - const response = await listRepositoryBranchPolicies(repositoryId, { cache: "no-store" }); - setRepositoryBranchPolicies( - repositoryId, - response.repository_branch_policies, - requestRevision, - ); - } catch { - // Keep the original task error visible when recovery cannot refresh. - } finally { - setRepositoryBranchPoliciesLoading(repositoryId, false); - } - }), - ); - }, [fs.repositories, setRepositoryBranchPolicies, setRepositoryBranchPoliciesLoading, storeApi]); + const refreshBranchPolicies = useRefreshBranchPolicies(fs); return { ...data, handlers, diff --git a/apps/web/components/task-create-dialog-state-seeded-executor.test.ts b/apps/web/components/task-create-dialog-state-seeded-executor.test.ts new file mode 100644 index 0000000000..91fb2cc5ef --- /dev/null +++ b/apps/web/components/task-create-dialog-state-seeded-executor.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useDialogFormState } from "./task-create-dialog-state"; +import { useDefaultSelectionsEffect } from "./task-create-dialog-effects"; +import type { StoreSelections } from "./task-create-dialog-types"; + +// `useBranchesByURL` triggers a real network ensure() when given a URL — stub +// it so the dialog state hook can mount in JSDOM without hitting fetch. The +// stubbed shape mirrors the production hook (branches/loading/ensure). +vi.mock("@/hooks/domains/github/use-branches-by-url", () => ({ + useBranchesByURL: () => ({ + branches: () => [], + loading: () => false, + ensure: () => undefined, + }), +})); + +vi.mock("@/hooks/domains/github/use-pr-info-by-url", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + usePRInfoByURL: () => ({ + info: () => undefined, + loading: () => false, + ensure: () => undefined, + clear: () => undefined, + }), + }; +}); + +const SEEDED_AUTOPICKED_PROFILE = "profile-autopicked"; + +describe("useDialogFormState — seededExecutorProfileId", () => { + // The submit flow decides whether the user "changed" the runner by + // comparing the final selection to whatever the dialog itself seeded, so + // only a write that actually originated from autopick/stored-profile + // seeding may become that baseline — never a value the user picked. + it("is null until a value is seeded", () => { + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + expect(result.current.seededExecutorProfileId).toBeNull(); + }); + + it("captures a seed write and keeps it despite later user changes", () => { + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + + act(() => { + result.current.setExecutorProfileIdFromSeed(SEEDED_AUTOPICKED_PROFILE); + }); + expect(result.current.seededExecutorProfileId).toBe(SEEDED_AUTOPICKED_PROFILE); + + act(() => { + result.current.setExecutorProfileId("profile-user-chosen"); + }); + expect(result.current.seededExecutorProfileId).toBe(SEEDED_AUTOPICKED_PROFILE); + + // Changing back to the seeded value doesn't create a second "seed". + act(() => { + result.current.setExecutorProfileId(SEEDED_AUTOPICKED_PROFILE); + }); + expect(result.current.seededExecutorProfileId).toBe(SEEDED_AUTOPICKED_PROFILE); + }); + + // Regression: a user fast enough to pick a runner before the seed effect + // fires (e.g. an edit-mode stored profile that arrives a tick after open) + // must not have that pick mistaken for the seed. Racing the user's + // setExecutorProfileId ahead of setExecutorProfileIdFromSeed must not let + // the user's own choice masquerade as "what the dialog put there". + it("does not treat a user pick that arrives before the seed write as the seed", () => { + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + + act(() => { + result.current.setExecutorProfileId("profile-user-picked-first"); + }); + expect(result.current.seededExecutorProfileId).toBeNull(); + + act(() => { + result.current.setExecutorProfileIdFromSeed(SEEDED_AUTOPICKED_PROFILE); + }); + expect(result.current.seededExecutorProfileId).toBe(SEEDED_AUTOPICKED_PROFILE); + }); + + it("resets to null on the next open cycle", () => { + const { result, rerender } = renderHook( + ({ open }: { open: boolean }) => useDialogFormState(open, "ws-1", null), + { initialProps: { open: true } }, + ); + + act(() => { + result.current.setExecutorProfileIdFromSeed("profile-first-cycle"); + }); + expect(result.current.seededExecutorProfileId).toBe("profile-first-cycle"); + + // Close then reopen: a rising edge bumps openCycle and the reset effects + // clear executorProfileId back to "". + rerender({ open: false }); + rerender({ open: true }); + + expect(result.current.seededExecutorProfileId).toBeNull(); + }); + + // Regression: on the same open-transition, the reset effect that bumps + // openCycle (useFormResetEffects) and the stored-profile seed effect + // (useDefaultSelectionsEffect) both fire and their setState calls land in + // the same subsequent render. A reset keyed on comparing openCycle in the + // render body cannot tell "openCycle changed because the dialog just + // opened" apart from "openCycle changed and a seed write from that very + // open already landed" — it must not wipe a seed that arrived on the same + // transition that triggered it. + it("keeps a stored-profile seed written on the same open transition that bumps openCycle", async () => { + const editingTaskExecutorProfileId = "profile-from-stored-task"; + const sel: StoreSelections = { + agentProfiles: [], + compatibleAgentProfiles: [], + authLoaded: true, + executors: [], + workspaceDefaults: null, + }; + + const { result } = renderHook(() => { + const fs = useDialogFormState(true, "ws-1", null); + useDefaultSelectionsEffect(fs, true, sel, [], editingTaskExecutorProfileId); + return fs; + }); + + await waitFor(() => + expect(result.current.executorProfileId).toBe(editingTaskExecutorProfileId), + ); + expect(result.current.seededExecutorProfileId).toBe(editingTaskExecutorProfileId); + }); +}); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 395f29121a..2fca3fcfd6 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -467,6 +467,11 @@ export function useDialogFormState( form.descriptionInputRef, ); + const { seededExecutorProfileId, setExecutorProfileIdFromSeed } = useSeededExecutorProfileId( + open, + form.setExecutorProfileId, + ); + return { ...form, ...discovery, @@ -479,9 +484,48 @@ export function useDialogFormState( branchesByUrl, prInfoByUrl, clearDraft, + seededExecutorProfileId, + setExecutorProfileIdFromSeed, }; } +/** + * Tracks the executorProfileId value written through + * setExecutorProfileIdFromSeed each open cycle, whether it arrives via the + * create-mode autopick or the edit-mode stored-profile seed effect. Resets + * on each open rising edge. This is "what the dialog put there", independent + * of any later or earlier user selection: only a write through the + * returned setExecutorProfileIdFromSeed counts, never the plain + * setExecutorProfileId a user's own picker uses. + * + * The reset is keyed on `open`'s own rising edge via an effect, not on + * comparing openCycle during render: openCycle bumps via its own effect on + * the same rising edge, and a seed write triggered by that same edge can + * land in the same subsequent render as the bump. A render-phase comparison + * can't distinguish "openCycle just changed because the dialog opened" from + * "openCycle changed and this cycle's seed already arrived", so it would + * wipe a same-transition seed the instant it was written. + */ +function useSeededExecutorProfileId(open: boolean, setExecutorProfileId: (v: string) => void) { + const seededRef = useRef(null); + const prevOpenRef = useRef(false); + useEffect(() => { + const wasOpen = prevOpenRef.current; + prevOpenRef.current = open; + if (open && !wasOpen) { + seededRef.current = null; + } + }, [open]); + const setExecutorProfileIdFromSeed = useCallback( + (value: string) => { + seededRef.current = value; + setExecutorProfileId(value); + }, + [setExecutorProfileId], + ); + return { seededExecutorProfileId: seededRef.current, setExecutorProfileIdFromSeed }; +} + /** * Seeds the task title from the first populated Remote row's GitHub URL info * the first time it arrives, and only when the user hasn't typed a title. diff --git a/apps/web/components/task-create-dialog-submit.test.tsx b/apps/web/components/task-create-dialog-submit.test.tsx index 847361e414..d6f2908f72 100644 --- a/apps/web/components/task-create-dialog-submit.test.tsx +++ b/apps/web/components/task-create-dialog-submit.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { createRef } from "react"; import { ApiError } from "@/lib/api/client"; +import { WebSocketRequestError } from "@/lib/ws/client"; // All external module mocks must be declared with vi.mock before the import of // the unit under test so vitest hoists them. The mocks below capture the @@ -96,6 +97,14 @@ vi.mock("@/components/task-create-dialog-helpers", () => ({ hasPendingAttachmentUploads: () => false, validateCreateInputs: (...args: unknown[]) => validateCreateInputsMock(...args), toMessageAttachments: () => [], + RUNNER_INELIGIBLE_REASON_KEYS: { + session_exists: "task:runnerReasonSessionExists", + }, +})); + +const switchTaskRunnerMock = vi.fn(async (..._args: unknown[]) => ({ id: TASK_ID })); +vi.mock("@/lib/api/domains/task-runner-api", () => ({ + switchTaskRunner: (...args: unknown[]) => switchTaskRunnerMock(...args), })); const createTaskRetryMock = vi.fn(async (buildPayload: (consented: string[]) => unknown) => { @@ -165,6 +174,7 @@ function makeDeps(overrides: Partial): SubmitHandlersDeps { agentProfileId: "agent-1", executorId: "exec-1", executorProfileId: "execp-1", + seededExecutorProfileId: null, editingTask: null, onSuccess: vi.fn(), onOpenChange: vi.fn(), @@ -205,6 +215,8 @@ beforeEach(() => { pushMock.mockClear(); toastMock.mockClear(); recordRecentUseMock.mockClear(); + switchTaskRunnerMock.mockClear(); + switchTaskRunnerMock.mockResolvedValue({ id: TASK_ID }); }); // eslint-disable-next-line max-lines-per-function -- grouped edit regressions share one fixture. @@ -465,6 +477,240 @@ describe("useTaskSubmitHandlers — started task edits", () => { }); }); +// eslint-disable-next-line max-lines-per-function -- runner-switch ordering regressions share one fixture. +describe("useTaskSubmitHandlers — runner switch (REQ-TASKS-RUNNER-SWITCH-004)", () => { + const EXISTING_PROFILE = "execp-seeded"; + const CHOSEN_PROFILE = "execp-chosen"; + + function editingTaskFixture() { + return { + id: TASK_ID, + title: ORIGINAL_TITLE, + description: ORIGINAL_PROMPT, + workflowStepId: "step-1", + state: TODO_STATE, + }; + } + + it("issues no switch when the final selection matches what was seeded", async () => { + const deps = makeDeps({ + isEditMode: true, + taskName: ORIGINAL_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(ORIGINAL_PROMPT), + executorProfileId: EXISTING_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).not.toHaveBeenCalled(); + expect(updateTaskMock).toHaveBeenCalled(); + }); + + it("issues no switch while nothing has been seeded yet", async () => { + const deps = makeDeps({ + isEditMode: true, + taskName: ORIGINAL_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(ORIGINAL_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: null, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).not.toHaveBeenCalled(); + }); + + it("issues the switch before the field update when the selection changed (AC-004.4a)", async () => { + const callOrder: string[] = []; + switchTaskRunnerMock.mockImplementationOnce(async () => { + callOrder.push("switch"); + return { id: TASK_ID }; + }); + updateTaskMock.mockImplementationOnce(async () => { + callOrder.push("update"); + return { id: TASK_ID, title: ORIGINAL_TITLE }; + }); + const deps = makeDeps({ + isEditMode: true, + taskName: ORIGINAL_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(ORIGINAL_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).toHaveBeenCalledWith(TASK_ID, CHOSEN_PROFILE); + expect(callOrder).toEqual(["switch", "update"]); + }); + + it("saves nothing and issues no launch when the switch is rejected (AC-004.4a/4d)", async () => { + const onOpenChange = vi.fn(); + switchTaskRunnerMock.mockRejectedValueOnce( + new WebSocketRequestError("conflict", "CONFLICT", { error_code: "session_exists" }), + ); + const deps = makeDeps({ + isEditMode: true, + taskName: RENAMED_TITLE, + agentProfileId: "agent-1", + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(UPDATED_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + onOpenChange, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleSubmit({ preventDefault: () => {} } as never); + }); + + expect(updateTaskMock).not.toHaveBeenCalled(); + expect(launchSessionMock).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: "The runner can't be changed because this task already has a session.", + }), + ); + }); + + it("reports an unrecognized outcome class for a not-found switch rejection (AC-004.4b)", async () => { + switchTaskRunnerMock.mockRejectedValueOnce(new WebSocketRequestError("gone", "NOT_FOUND")); + const deps = makeDeps({ + isEditMode: true, + taskName: ORIGINAL_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(ORIGINAL_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ description: "This task could not be found." }), + ); + }); + + it("reports a truthful partial save when the field update fails after the switch committed (AC-004.4c)", async () => { + const onOpenChange = vi.fn(); + updateTaskMock.mockRejectedValueOnce(new Error("network blip")); + const deps = makeDeps({ + isEditMode: true, + taskName: RENAMED_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(UPDATED_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + onOpenChange, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).toHaveBeenCalledWith(TASK_ID, CHOSEN_PROFILE); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "The runner was switched, but the rest of your changes couldn't be saved. Your other edits are still shown below: try saving again.", + }), + ); + }); + + it("switches back to the user's selection after retrying a partial save", async () => { + const onOpenChange = vi.fn(); + updateTaskMock.mockRejectedValueOnce(new Error("network blip")); + updateTaskMock.mockResolvedValueOnce({ id: TASK_ID, title: ORIGINAL_TITLE }); + const initialDeps = makeDeps({ + isEditMode: true, + taskName: ORIGINAL_TITLE, + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(ORIGINAL_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + onOpenChange, + }); + const { result, rerender } = renderHook( + ({ deps }: { deps: SubmitHandlersDeps }) => useTaskSubmitHandlers(deps), + { initialProps: { deps: initialDeps } }, + ); + + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).toHaveBeenCalledWith(TASK_ID, CHOSEN_PROFILE); + expect(onOpenChange).not.toHaveBeenCalled(); + + rerender({ + deps: { + ...initialDeps, + executorProfileId: EXISTING_PROFILE, + }, + }); + await act(async () => { + await result.current.handleUpdateWithoutAgent(); + }); + + expect(switchTaskRunnerMock).toHaveBeenNthCalledWith(2, TASK_ID, EXISTING_PROFILE); + expect(updateTaskMock).toHaveBeenCalledTimes(2); + }); + + it("reports a truthful partial save when the launch fails after the save committed (AC-004.4c/4d)", async () => { + const onOpenChange = vi.fn(); + const onSuccess = vi.fn(); + launchSessionMock.mockRejectedValueOnce(new Error("agent process crashed")); + const deps = makeDeps({ + isEditMode: true, + taskName: RENAMED_TITLE, + agentProfileId: "agent-1", + editingTask: editingTaskFixture(), + descriptionInputRef: makeRef(UPDATED_PROMPT), + executorProfileId: CHOSEN_PROFILE, + seededExecutorProfileId: EXISTING_PROFILE, + onOpenChange, + onSuccess, + }); + const { result } = renderHook(() => useTaskSubmitHandlers(deps)); + + await act(async () => { + await result.current.handleSubmit({ preventDefault: () => {} } as never); + }); + + expect(switchTaskRunnerMock).toHaveBeenCalledWith(TASK_ID, CHOSEN_PROFILE); + expect(updateTaskMock).toHaveBeenCalled(); + expect(launchSessionMock).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "Your changes were saved, but the agent could not be started. Try starting it again.", + }), + ); + }); +}); + describe("useTaskSubmitHandlers — repository selection edit failures", () => { const failures = [ { diff --git a/apps/web/components/task-create-dialog-submit.tsx b/apps/web/components/task-create-dialog-submit.tsx index f18ca0692c..6bae885e30 100644 --- a/apps/web/components/task-create-dialog-submit.tsx +++ b/apps/web/components/task-create-dialog-submit.tsx @@ -2,7 +2,7 @@ /* eslint-disable max-lines -- create and edit submit flows share one lifecycle boundary. */ -import { useCallback, FormEvent } from "react"; +import { useCallback, useRef, FormEvent } from "react"; import { useRouter } from "@/lib/routing/client-router"; import { updateTask } from "@/lib/api"; import { useAppStore } from "@/components/state-provider"; @@ -18,6 +18,8 @@ import { ApiError } from "@/lib/api/client"; import { getTaskDependencyCycle } from "@/lib/api/domains/task-dependencies-api"; import { isTaskDependencyUpdateFailure } from "@/hooks/domains/task/use-task-edit-dialog-dependencies"; import { recordAgentProfileRecentUseBestEffort } from "@/lib/agent-profile-recent-use"; +import { switchTaskRunner } from "@/lib/api/domains/task-runner-api"; +import { WebSocketRequestError } from "@/lib/ws/client"; const GENERIC_ERROR_KEY = "common:anErrorOccurred"; @@ -31,6 +33,7 @@ import { validateCreateInputs, hasPendingAttachmentUploads, toMessageAttachments, + RUNNER_INELIGIBLE_REASON_KEYS, } from "@/components/task-create-dialog-helpers"; import { hasRegisteredRepositoryProviderCandidate } from "@/lib/plugins/repository-provider-url-resolution"; @@ -85,12 +88,70 @@ const REPOSITORY_SELECTION_ERROR_KEYS: Record = { repository_selection_unavailable: "task:repositorySelectionUnavailable", }; +/** + * Wraps a rejected `task.runner` switch: the save issues no other call once + * this throws, so `performTaskUpdate` never reaches `updateTask`. + */ +class RunnerSwitchRejectedError extends Error { + constructor(readonly cause: unknown) { + super("runner switch rejected"); + } +} + +/** + * Wraps a failure in the sequence AFTER a runner switch already committed: + * the switch is not rolled back, so this exists to tell that state apart + * from an ordinary save failure. + */ +class TaskUpdateAfterRunnerSwitchError extends Error { + constructor(readonly cause: unknown) { + super("task update failed after runner switch committed"); + } +} + +/** + * Wraps a session-launch failure that follows an already-committed task + * save: the save is not rolled back, so this exists to tell that state + * apart from an ordinary save failure and keep the dialog reporting the + * truth (saved, but the agent didn't start) instead of a silent success. + */ +class LaunchAfterTaskUpdateError extends Error { + constructor(readonly cause: unknown) { + super("session launch failed after task update committed"); + } +} + +// Maps a rejected task.runner switch to outcome-specific text: a typed +// mutability conflict reuses the same reason copy as the read-side +// projection; an untyped outcome (invalid, not-found, +// evaluation-unavailable) gets text for that class instead of the raw wire +// code. +function runnerSwitchErrorMessage(error: unknown): string { + if (error instanceof WebSocketRequestError) { + const errorCode = + typeof error.details?.error_code === "string" ? error.details.error_code : undefined; + if (errorCode === "target_cannot_materialize_repository") { + return t("task:runnerConflictTargetCannotMaterializeRepository"); + } + const reasonKey = errorCode ? RUNNER_INELIGIBLE_REASON_KEYS[errorCode] : undefined; + if (reasonKey) return t(reasonKey); + if (error.code === "NOT_FOUND") return t("task:runnerSwitchNotFound"); + if (error.code === "VALIDATION_ERROR") return t("task:runnerSwitchInvalid"); + if (error.code === "UNAVAILABLE") return t("task:runnerReasonEvaluationUnavailable"); + } + return error instanceof Error ? error.message : t(GENERIC_ERROR_KEY); +} + export function taskSubmitErrorMessage(error: unknown): string { if (isTaskDependencyUpdateFailure(error)) { const cycle = getTaskDependencyCycle(error.cause); if (cycle?.length) return t("task:dependencyCycleError", { cycle: cycle.join(" -> ") }); return t("task:dependencyUpdateFailed"); } + if (error instanceof RunnerSwitchRejectedError) return runnerSwitchErrorMessage(error.cause); + if (error instanceof TaskUpdateAfterRunnerSwitchError) + return t("task:runnerSwitchPartiallySaved"); + if (error instanceof LaunchAfterTaskUpdateError) return t("task:launchFailedAfterTaskSaved"); if (error instanceof ApiError) { const key = REPOSITORY_SELECTION_ERROR_KEYS[error.errorCode ?? ""]; if (key) return t(key); @@ -136,12 +197,76 @@ function areEditDependenciesReady( return !isEditMode || editDependencies?.ready !== false; } +// Only a final selection that differs from the last confirmed stored runner +// counts as a user change. The baseline advances after a successful switch, +// so a retry can persist a deliberate change back to the previous profile. +function computeRunnerChanged( + confirmedExecutorProfileId: string | null, + executorProfileId: string, +): boolean { + return ( + confirmedExecutorProfileId !== null && + executorProfileId !== "" && + executorProfileId !== confirmedExecutorProfileId + ); +} + +// Issued first. A rejection here must leave every other field unsaved, so +// the caller never reaches the rest of the save sequence. +async function issueRunnerSwitchIfChanged( + runnerChanged: boolean, + taskId: string, + executorProfileId: string, + markRunnerConfirmed: (executorProfileId: string) => void, +): Promise { + if (!runnerChanged) return; + try { + await switchTaskRunner(taskId, executorProfileId); + markRunnerConfirmed(executorProfileId); + } catch (error) { + throw new RunnerSwitchRejectedError(error); + } +} + +type SaveEditedTaskFieldsArgs = { + editingTask: { id: string }; + updatePayload: Parameters[1]; + trimmedDescription: string; + runnerChanged: boolean; +} & Omit; + +// A runner switch that already committed is never rolled back; tag a +// failure here so the caller can report the true partial state instead of +// implying the whole save was rejected. +async function saveEditedTaskFields({ + editingTask, + updatePayload, + trimmedDescription, + runnerChanged, + ...dependencySaveArgs +}: SaveEditedTaskFieldsArgs) { + try { + const updatedTask = await updateTask(editingTask.id, updatePayload); + await saveEditedTaskDependencies({ ...dependencySaveArgs, updatedTask }); + return { updatedTask, trimmedDescription }; + } catch (error) { + if (runnerChanged) throw new TaskUpdateAfterRunnerSwitchError(error); + throw error; + } +} + async function shouldKeepEditDialogOpen( error: unknown, refreshStaleBranchPolicies: (error: unknown) => Promise, ): Promise { if (isRepositorySelectionError(error)) return true; if (isTaskDependencyUpdateFailure(error)) return true; + // A rejected switch, or a later call failing after the switch already + // committed, both need the user back in the dialog to see the reason and + // retry. + if (error instanceof RunnerSwitchRejectedError) return true; + if (error instanceof TaskUpdateAfterRunnerSwitchError) return true; + if (error instanceof LaunchAfterTaskUpdateError) return true; return refreshStaleBranchPolicies(error); } @@ -172,6 +297,7 @@ export function useTaskSubmitHandlers({ agentProfileId, executorId, executorProfileId, + seededExecutorProfileId, editingTask, onSuccess, onCreateSession, @@ -210,6 +336,15 @@ export function useTaskSubmitHandlers({ const setPlanMode = useAppStore((state) => state.setPlanMode); const applyAgentProfileRecentUse = useAppStore((state) => state.applyAgentProfileRecentUse); const isStartedEdit = computeIsTaskStarted(isEditMode, editingTask); + const seededRunnerRef = useRef(seededExecutorProfileId); + const confirmedRunnerRef = useRef(seededExecutorProfileId); + if (seededRunnerRef.current !== seededExecutorProfileId) { + seededRunnerRef.current = seededExecutorProfileId; + confirmedRunnerRef.current = seededExecutorProfileId; + } + const markRunnerConfirmed = useCallback((profileId: string) => { + confirmedRunnerRef.current = profileId; + }, []); const isFreshBranchActive = freshBranchEnabled && isLocalExecutor && !useRemote && repositoryLocalPath !== ""; @@ -422,6 +557,15 @@ export function useTaskSubmitHandlers({ if (!areEditDependenciesReady(isEditMode, editDependencies)) return null; const trimmedTitle = taskName.trim(); if (!trimmedTitle) return null; + + const runnerChanged = computeRunnerChanged(confirmedRunnerRef.current, executorProfileId); + await issueRunnerSwitchIfChanged( + runnerChanged, + editingTask.id, + executorProfileId, + markRunnerConfirmed, + ); + const description = isStartedEdit ? (editingTask.description ?? "") : (descriptionInputRef.current?.getValue() ?? ""); @@ -435,16 +579,17 @@ export function useTaskSubmitHandlers({ ...(!isStartedEdit && repositoriesDirty && { repositories: repositoriesPayload }), }; - const updatedTask = await updateTask(editingTask.id, updatePayload); - await saveEditedTaskDependencies({ + return saveEditedTaskFields({ + editingTask, + updatePayload, + trimmedDescription, + runnerChanged, editDependencies, - updatedTask, isStartedEdit, descriptionInputRef, setTaskName, setHasDescription, }); - return { updatedTask, trimmedDescription }; }, [ editingTask, taskName, @@ -456,6 +601,8 @@ export function useTaskSubmitHandlers({ isEditMode, setTaskName, setHasDescription, + executorProfileId, + markRunnerConfirmed, ]); const handleEditSubmit = useCallback(async () => { @@ -486,7 +633,11 @@ export function useTaskSubmitHandlers({ ); } } catch (error) { - console.error("[TaskCreateDialog] failed to start agent:", error); + // The task save already committed by this point (performTaskUpdate + // resolved above); the launch is the last call in AC-004.4c's + // ordered sequence, so its failure must be reported as a partial + // save rather than swallowed into an apparent full success. + throw new LaunchAfterTaskUpdateError(error); } } diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index ef4be95b03..1b71cd89a3 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -37,6 +37,25 @@ export type TaskCreateSubmit = ( payload: Parameters[0], ) => Promise; +/** + * Shape of the task being edited that both `TaskCreateDialogProps.editingTask` + * and `SubmitHandlersDeps.editingTask` need. `runnerEditable`/ + * `runnerIneligibleReason` gate the executor-profile selector independently + * of `state`. + */ +export type TaskEditTarget = { + id: string; + title: string; + description?: string; + workflowStepId: string; + state?: Task["state"]; + repositoryId?: string; + repositories?: TaskRepositorySnapshot[]; + primaryExecutorProfileId?: string; + runnerEditable?: boolean; + runnerIneligibleReason?: string; +}; + export interface TaskCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -52,15 +71,7 @@ export interface TaskCreateDialogProps { on_turn_complete?: Array<{ type: string; config?: Record }>; }; }>; - editingTask?: { - id: string; - title: string; - description?: string; - workflowStepId: string; - state?: Task["state"]; - repositoryId?: string; - repositories?: TaskRepositorySnapshot[]; - } | null; + editingTask?: TaskEditTarget | null; onSuccess?: ( task: Task, mode: "create" | "edit", @@ -357,6 +368,12 @@ export type TaskCreateEffectsArgs = { * clobbered by the executor's async settle on mount. */ preserveBranch?: string; + /** + * The task's own stored executor profile, when editing a task that has one. + * Seeds the picker directly instead of the create-mode "resolve a default" + * autopick. + */ + editingTaskExecutorProfileId?: string | null; }; import type { FileAttachment } from "@/components/task/chat/file-attachment"; @@ -427,6 +444,21 @@ export type DialogFormState = { setExecutorId: (v: string) => void; executorProfileId: string; setExecutorProfileId: (v: string) => void; + /** + * Writes executorProfileId from an autopick/stored-profile seed effect + * only, never from the user's own picker. This is the sole writer + * seededExecutorProfileId tracks, so a user selection can never be + * mistaken for a seed regardless of which write lands first. + */ + setExecutorProfileIdFromSeed: (v: string) => void; + /** + * The executor profile id the dialog seeded for this open cycle (stored + * value in edit mode, resolved default in create mode) — never a value the + * user chose. Null until a value has been seeded. Submit flows compare the + * final selection against this to decide whether the user actually changed + * the runner. + */ + seededExecutorProfileId: string | null; discoveredRepositories: LocalRepository[]; setDiscoveredRepositories: (v: LocalRepository[]) => void; discoverReposLoading: boolean; @@ -510,15 +542,9 @@ export type SubmitHandlersDeps = { agentProfileId: string; executorId: string; executorProfileId: string; - editingTask?: { - id: string; - title: string; - description?: string; - workflowStepId: string; - state?: Task["state"]; - repositoryId?: string; - repositories?: TaskRepositorySnapshot[]; - } | null; + /** See {@link DialogFormState.seededExecutorProfileId}. */ + seededExecutorProfileId: string | null; + editingTask?: TaskEditTarget | null; onSuccess?: ( task: Task, mode: "create" | "edit", @@ -690,4 +716,8 @@ export type DialogFormBodyProps = { * hands-free. */ onComposerSubmit?: () => boolean | Promise; + /** From computeRunnerEditable: gates the executor-profile selector independently of isTaskStarted. */ + runnerEditable: boolean; + /** From computeRunnerIneligibleReason: presented when runnerEditable is false. */ + runnerIneligibleReason: string; }; diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index 8f09d59e94..7a3ea86087 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -308,6 +308,8 @@ function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState { setExecutorId: () => undefined, executorProfileId: "executor-profile-1", setExecutorProfileId: () => undefined, + setExecutorProfileIdFromSeed: () => undefined, + seededExecutorProfileId: null, discoveredRepositories: [], setDiscoveredRepositories: () => undefined, discoverReposLoading: false, diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 039f9abcf3..40fa8d5a22 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -142,6 +142,8 @@ function CreateModeAgentSelectors(props: DialogFormBodyProps) { selectedAgentProfileName={props.selectedAgentProfileName} effectiveWorkflowName={props.effectiveWorkflowName} executorProfileName={props.executorProfileName} + runnerEditable={props.runnerEditable} + runnerIneligibleReason={props.runnerIneligibleReason} /> ); } diff --git a/apps/web/components/task/new-subtask-form-state.ts b/apps/web/components/task/new-subtask-form-state.ts index c012832546..7fb9cb9aec 100644 --- a/apps/web/components/task/new-subtask-form-state.ts +++ b/apps/web/components/task/new-subtask-form-state.ts @@ -99,6 +99,10 @@ export function useSubtaskFormState(workspaceId: string | null): DialogFormState setExecutorId: NOOP, executorProfileId, setExecutorProfileId, + // The New Subtask dialog is create-only — there is no editing task to + // seed a stored profile from, so nothing is ever "seeded" here. + setExecutorProfileIdFromSeed: NOOP, + seededExecutorProfileId: null, autopilot, setAutopilot, discoveredRepositories, diff --git a/apps/web/components/task/task-session-sidebar-edit.test.ts b/apps/web/components/task/task-session-sidebar-edit.test.ts index 1d15594f61..22fbca618b 100644 --- a/apps/web/components/task/task-session-sidebar-edit.test.ts +++ b/apps/web/components/task/task-session-sidebar-edit.test.ts @@ -45,6 +45,20 @@ describe("buildSidebarTaskEditTarget", () => { ).toBeNull(); }); + it("carries the runner-mutability projection and stored executor profile", () => { + const target = buildSidebarTaskEditTarget( + { id: "task-1", workflowId: "workflow-1", workflowStepId: "step-1" }, + sourceTask({ + primaryExecutorProfileId: "profile-1", + runnerEditable: false, + runnerIneligibleReason: "session_exists", + }), + ); + expect(target?.primaryExecutorProfileId).toBe("profile-1"); + expect(target?.runnerEditable).toBe(false); + expect(target?.runnerIneligibleReason).toBe("session_exists"); + }); + it("falls back to the sidebar step when the source task has no step", () => { expect( buildSidebarTaskEditTarget( diff --git a/apps/web/components/task/task-session-sidebar-edit.tsx b/apps/web/components/task/task-session-sidebar-edit.tsx index 6fbf33e6b1..0e79122f09 100644 --- a/apps/web/components/task/task-session-sidebar-edit.tsx +++ b/apps/web/components/task/task-session-sidebar-edit.tsx @@ -19,6 +19,9 @@ export type SidebarTaskEditTarget = { state?: Task["state"]; repositoryId?: string; repositories?: TaskRepositorySnapshot[]; + primaryExecutorProfileId?: string; + runnerEditable?: boolean; + runnerIneligibleReason?: string; }; export function buildSidebarTaskEditTarget( @@ -38,6 +41,9 @@ export function buildSidebarTaskEditTarget( state: sourceTask.state, repositoryId: sourceTask.repositoryId ?? undefined, repositories: sourceTask.repositories, + primaryExecutorProfileId: sourceTask.primaryExecutorProfileId ?? undefined, + runnerEditable: sourceTask.runnerEditable, + runnerIneligibleReason: sourceTask.runnerIneligibleReason, }; } @@ -96,6 +102,9 @@ export function SidebarTaskEditDialog({ state: target.state, repositoryId: target.repositoryId, repositories: target.repositories, + primaryExecutorProfileId: target.primaryExecutorProfileId, + runnerEditable: target.runnerEditable, + runnerIneligibleReason: target.runnerIneligibleReason, } : null } diff --git a/apps/web/e2e/tests/task/mobile-runner-switch.spec.ts b/apps/web/e2e/tests/task/mobile-runner-switch.spec.ts new file mode 100644 index 0000000000..6df7657b4a --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-runner-switch.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "../../fixtures/test-base"; +import { watchWs } from "../../helpers/causal-waits"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import type { ApiClient } from "../../helpers/api-client"; +import type { Page } from "@playwright/test"; + +// REQ-TASKS-RUNNER-SWITCH-004.6: desktop and mobile use the same gate, the +// same reason text, and the same interaction — this capability introduces no +// new mobile layout or pattern, so these mirror runner-switch.spec.ts's +// desktop scenarios through the mobile task-actions sheet instead of a +// bespoke mobile flow. +useRegularMode(); + +type ExecutorProfile = { id: string; name: string }; +type ExecutorSummary = { id: string; type: string; profiles?: ExecutorProfile[] }; + +async function twoExecutorProfiles( + apiClient: ApiClient, +): Promise<{ first: ExecutorProfile; second: ExecutorProfile }> { + const { executors } = await apiClient.listExecutors(); + const worktreeExecutor = executors.find( + (executor: ExecutorSummary) => executor.type === "worktree", + ); + const localExecutor = executors.find((executor: ExecutorSummary) => + ["local", "local_pc"].includes(executor.type), + ); + const first = worktreeExecutor?.profiles?.[0]; + const second = localExecutor?.profiles?.[0]; + expect(first, "a worktree executor profile is required by the fixture").toBeDefined(); + expect(second, "a local executor profile is required by the fixture").toBeDefined(); + return { first: first!, second: second! }; +} + +async function openEditDialogFromCard(page: Page, mobile: MobileKanbanPage, taskId: string) { + await mobile.taskCard(taskId).getByRole("button", { name: "More options" }).tap(); + await page + .locator('[data-slot="dropdown-menu-content"]:visible') + .getByRole("menuitem", { name: "Edit", exact: true }) + .tap(); + const dialog = page.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +test.describe("Runner switch before materialization on mobile", () => { + test("switching the executor profile through the mobile task-actions sheet updates the stored runner", async ({ + testPage, + apiClient, + seedData, + }) => { + const { first, second } = await twoExecutorProfiles(apiClient); + const task = await apiClient.createTask( + seedData.workspaceId, + "Mobile runner switch eligible task", + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await apiClient.updateTaskMetadata(task.id, { executor_profile_id: first.id }); + + const ws = watchWs(testPage); + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + const dialog = await openEditDialogFromCard(testPage, mobile, task.id); + + // Touch-target sizing for this selector is pre-existing (unchanged by + // this feature, see task-create-dialog-selectors.tsx) and out of scope + // here; this test covers only the runner-switch gating and interaction. + const executorSelector = dialog.getByTestId("executor-profile-selector"); + await expect(executorSelector).toBeVisible(); + + await executorSelector.tap(); + await testPage.getByRole("option", { name: new RegExp(second.name) }).tap(); + + const switched = ws.waitForResponse("task.runner"); + await dialog.getByRole("button", { name: "Update task", exact: true }).tap(); + await switched; + await expect(dialog).toHaveCount(0); + + const updated = await apiClient.getTask(task.id); + expect(updated.metadata?.executor_profile_id).toBe(second.id); + }); + + test("a task with an existing session presents the same reason text as desktop", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile runner switch ineligible task", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await expect + .poll(async () => (await apiClient.getTask(task.id)).primary_session_id ?? null) + .not.toBeNull(); + + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + const dialog = await openEditDialogFromCard(testPage, mobile, task.id); + + const note = dialog.getByTestId("runner-ineligible-note"); + await expect(note).toBeVisible(); + await expect(note).toContainText("already has a session"); + await expect(dialog.getByTestId("executor-profile-selector")).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/task/runner-switch.spec.ts b/apps/web/e2e/tests/task/runner-switch.spec.ts new file mode 100644 index 0000000000..ef45232981 --- /dev/null +++ b/apps/web/e2e/tests/task/runner-switch.spec.ts @@ -0,0 +1,202 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "../../fixtures/test-base"; +import { watchWs } from "../../helpers/causal-waits"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { KanbanPage } from "../../pages/kanban-page"; +import type { ApiClient } from "../../helpers/api-client"; + +// REQ-TASKS-RUNNER-SWITCH-004: the executor-profile picker in the task edit +// dialog, gated by the server-projected runner_editable verdict rather than +// task state. Exercises the regular task-create/edit dialog, so run with the +// office feature disabled (see useRegularMode). +useRegularMode(); + +type ExecutorProfile = { id: string; name: string }; +type ExecutorSummary = { id: string; type: string; profiles?: ExecutorProfile[] }; + +async function twoExecutorProfiles( + apiClient: ApiClient, +): Promise<{ first: ExecutorProfile; second: ExecutorProfile }> { + const { executors } = await apiClient.listExecutors(); + const worktreeExecutor = executors.find( + (executor: ExecutorSummary) => executor.type === "worktree", + ); + const localExecutor = executors.find((executor: ExecutorSummary) => + ["local", "local_pc"].includes(executor.type), + ); + const first = worktreeExecutor?.profiles?.[0]; + const second = localExecutor?.profiles?.[0]; + expect(first, "a worktree executor profile is required by the fixture").toBeDefined(); + expect(second, "a local executor profile is required by the fixture").toBeDefined(); + return { first: first!, second: second! }; +} + +async function openEditDialog(page: Page, kanban: KanbanPage, taskId: string) { + await kanban.openTaskActionsMenu(taskId); + await page.getByRole("menuitem", { name: "Edit", exact: true }).click(); + const dialog = page.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +// A not-started task's edit dialog also auto-picks a default agent, which +// flips the footer to the split "Start task" button (showStartTask in +// task-create-dialog-footer.tsx) instead of a plain "Update task" button. +// The plain button only appears once an agent has been explicitly cleared. +// Saving without launching a session means opening the split button's +// dropdown and choosing its "Update task" alt action either way. +async function saveEditWithoutStartingAgent( + page: Page, + dialog: import("@playwright/test").Locator, +) { + const chevron = dialog.getByTestId("submit-start-agent-chevron"); + if (await chevron.isVisible()) { + await chevron.click(); + await page.getByTestId("submit-create-without-agent").click(); + return; + } + await dialog.getByRole("button", { name: "Update task", exact: true }).click(); +} + +test.describe("Runner switch before materialization", () => { + test("switching the executor profile on an eligible task updates the stored runner", async ({ + testPage, + apiClient, + seedData, + }) => { + const { first, second } = await twoExecutorProfiles(apiClient); + const task = await apiClient.createTask(seedData.workspaceId, "Runner switch eligible task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + // `executor_profile_id` on the create body is only persisted to metadata + // when an agent_profile_id is also set (task_http_handlers.go), so a + // plain task needs the stored profile written directly to give the + // dialog a genuine "stored choice" to seed from, distinct from the + // "resolved default" case covered below. + await apiClient.updateTaskMetadata(task.id, { executor_profile_id: first.id }); + + const ws = watchWs(testPage); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const dialog = await openEditDialog(testPage, kanban, task.id); + + const executorSelector = dialog.getByTestId("executor-profile-selector"); + await expect(executorSelector).toBeVisible(); + await expect(dialog.getByTestId("runner-ineligible-note")).toHaveCount(0); + await expect(executorSelector).toContainText(first.name); + + await executorSelector.click(); + await testPage.getByRole("option", { name: new RegExp(second.name) }).click(); + + const switched = ws.waitForResponse("task.runner"); + await saveEditWithoutStartingAgent(testPage, dialog); + await switched; + await expect(dialog).toHaveCount(0); + + const updated = await apiClient.getTask(task.id); + expect(updated.metadata?.executor_profile_id).toBe(second.id); + }); + + test("a task with an existing session presents the reason instead of the picker", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Runner switch ineligible task", + seedData.agentProfileId, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await expect + .poll(async () => (await apiClient.getTask(task.id)).primary_session_id ?? null) + .not.toBeNull(); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const dialog = await openEditDialog(testPage, kanban, task.id); + + const note = dialog.getByTestId("runner-ineligible-note"); + await expect(note).toBeVisible(); + await expect(note).toContainText("already has a session"); + await expect(dialog.getByTestId("executor-profile-selector")).toHaveCount(0); + }); + + test("a switch rejected after the dialog opened leaves the dialog open with no partial save", async ({ + testPage, + apiClient, + seedData, + }) => { + const { first, second } = await twoExecutorProfiles(apiClient); + const task = await apiClient.createTask(seedData.workspaceId, "Runner switch race task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.updateTaskMetadata(task.id, { executor_profile_id: first.id }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const dialog = await openEditDialog(testPage, kanban, task.id); + + const executorSelector = dialog.getByTestId("executor-profile-selector"); + await executorSelector.click(); + await testPage.getByRole("option", { name: new RegExp(second.name) }).click(); + + // Simulate a concurrent change that makes the task ineligible between the + // dialog opening (with a stale eligible snapshot) and Save: the server + // re-evaluates the gate inside the switch transaction and must reject. + await apiClient.archiveTask(task.id); + + await saveEditWithoutStartingAgent(testPage, dialog); + + const toast = testPage.getByTestId("toast-message"); + await expect(toast).toBeVisible(); + await expect(toast).toContainText("archived"); + await expect(dialog).toBeVisible(); + + const afterRejection = await apiClient.getTask(task.id); + expect(afterRejection.metadata?.executor_profile_id).toBe(first.id); + }); + + test("saving without changing a seeded default profile issues no switch", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Runner switch untouched task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + expect((await apiClient.getTask(task.id)).metadata?.executor_profile_id).toBeUndefined(); + + const ws = watchWs(testPage); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + const dialog = await openEditDialog(testPage, kanban, task.id); + + // The picker seeds a resolved default (AC-TASKS-RUNNER-SWITCH-004.5a); + // leaving it untouched must not pin that default as an explicit switch. + await expect(dialog.getByTestId("executor-profile-selector")).toBeVisible(); + await dialog.getByTestId("task-title-input").fill("Runner switch untouched task (renamed)"); + + const rejectedRunnerCall = ws + .waitForResponse("task.runner", { timeout: 3_000 }) + .then(() => "sent" as const) + .catch(() => "not-sent" as const); + await saveEditWithoutStartingAgent(testPage, dialog); + await expect(dialog).toHaveCount(0); + expect(await rejectedRunnerCall).toBe("not-sent"); + + const updated = await apiClient.getTask(task.id); + expect(updated.title).toBe("Runner switch untouched task (renamed)"); + expect(updated.metadata?.executor_profile_id).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/api/domains/task-runner-api.test.ts b/apps/web/lib/api/domains/task-runner-api.test.ts new file mode 100644 index 0000000000..7a1266e932 --- /dev/null +++ b/apps/web/lib/api/domains/task-runner-api.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, it, expect, vi } from "vitest"; + +const getWebSocketClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: getWebSocketClientMock, +})); + +import { switchTaskRunner } from "./task-runner-api"; + +beforeEach(() => { + getWebSocketClientMock.mockReset(); +}); + +describe("switchTaskRunner", () => { + it("sends id and executor_profile_id through task.runner", async () => { + const request = vi.fn().mockResolvedValue({ id: "task-1", runner_editable: false }); + getWebSocketClientMock.mockReturnValue({ request }); + + const result = await switchTaskRunner("task-1", "profile-2"); + + expect(request).toHaveBeenCalledWith("task.runner", { + id: "task-1", + executor_profile_id: "profile-2", + }); + expect(result).toEqual({ id: "task-1", runner_editable: false }); + }); + + it("throws when no WebSocket client is connected", async () => { + getWebSocketClientMock.mockReturnValue(null); + + await expect(switchTaskRunner("task-1", "profile-2")).rejects.toThrow( + "WebSocket client not available", + ); + }); + + it("propagates a rejection from the WebSocket client", async () => { + const request = vi.fn().mockRejectedValue({ + code: "CONFLICT", + message: "The runner cannot be changed right now.", + details: { error_code: "session_exists" }, + }); + getWebSocketClientMock.mockReturnValue({ request }); + + await expect(switchTaskRunner("task-1", "profile-2")).rejects.toMatchObject({ + code: "CONFLICT", + details: { error_code: "session_exists" }, + }); + }); +}); diff --git a/apps/web/lib/api/domains/task-runner-api.ts b/apps/web/lib/api/domains/task-runner-api.ts new file mode 100644 index 0000000000..642af0c3ed --- /dev/null +++ b/apps/web/lib/api/domains/task-runner-api.ts @@ -0,0 +1,23 @@ +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { Task } from "@/lib/types/http"; + +// i18n-exempt: diagnostic thrown to the caller, never rendered. +const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; + +/** + * Switches a task's executor profile before anything has materialized. + * Rejects with a `WebSocketRequestError` carrying the outcome's `code` and, + * for a mutability or compatibility conflict, `details.error_code` naming + * the reason. + */ +export async function switchTaskRunner(taskId: string, executorProfileId: string): Promise { + 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; +} diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 12b02b27a3..58973ce421 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -341,6 +341,34 @@ describe("toKanbanTask priority", () => { }); }); +describe("toKanbanTask runner mutability", () => { + it("carries an explicit true/false and reason through both task shapes", () => { + const http = toKanbanTask( + httpDTO({ runner_editable: true, runner_ineligible_reason: "eligible" }), + ); + expect(http.runnerEditable).toBe(true); + expect(http.runnerIneligibleReason).toBe("eligible"); + + const ws = toKanbanTask( + wsPayload({ runner_editable: false, runner_ineligible_reason: "session_exists" }), + ); + expect(ws.runnerEditable).toBe(false); + expect(ws.runnerIneligibleReason).toBe("session_exists"); + }); + + // AC-TASKS-RUNNER-SWITCH-001.9: an omitted projection must fail closed, never + // read as the last-known value — unlike executor identity, this is not + // gap-filled from a cached task on merge (see mergeTaskUpdate in + // lib/ws/handlers/tasks.ts, which has no preserve entry for these fields). + it("fails closed to editable=false when the source omits the projection", () => { + const task = toKanbanTask( + httpDTO({ runner_editable: undefined, runner_ineligible_reason: undefined }), + ); + expect(task.runnerEditable).toBe(false); + expect(task.runnerIneligibleReason).toBe("evaluation_unavailable"); + }); +}); + describe("preserveOmittedExecutorFields", () => { const existing = toKanbanTask( httpDTO({ diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 2165990801..c71d6a2d4f 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -100,6 +100,8 @@ export type TaskLike = { archived_at?: string | null; status_summary?: TaskStatusSummary | null; status_summary_invalidated?: boolean; + runner_editable?: boolean; + runner_ineligible_reason?: string; }; export type WorkspaceMode = "inherit_parent" | "new_workspace" | "shared_group"; @@ -184,6 +186,19 @@ function primaryExecutorProjection(source: TaskLike) { }; } +/** + * Unlike {@link primaryExecutorProjection}, an omitted value here maps to the + * fail-closed default rather than `undefined` — this projection must never be + * gap-filled from a cached task on merge (a permission-shaped flag going + * stale-open is worse than it going stale-closed). + */ +function runnerMutabilityProjection(source: TaskLike) { + return { + runnerEditable: source.runner_editable ?? false, + runnerIneligibleReason: source.runner_ineligible_reason ?? "evaluation_unavailable", + }; +} + /** * Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket * payload. Both paths share this helper so a single publisher change can never @@ -285,6 +300,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { sessionCount: source.session_count ?? undefined, reviewStatus: source.review_status ?? undefined, ...primaryExecutorProjection(source), + ...runnerMutabilityProjection(source), primaryAgentProfileId: source.primary_agent_profile_id ?? undefined, primaryAgentName: source.primary_agent_name ?? undefined, labels: pickLabels(source), diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index e4ae13db2f..ccb24edab8 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -185,6 +185,10 @@ export type KanbanState = { issueUrl?: string; issueNumber?: number; statusSummary?: TaskStatusSummary | null; + /** Whether the executor profile can be switched right now. Never gap-filled on merge. */ + runnerEditable?: boolean; + /** Machine-readable reason for `runnerEditable`. Never gap-filled on merge. */ + runnerIneligibleReason?: string; }>; isLoading?: boolean; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 8ecf2f0c59..99710ca4d9 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -496,6 +496,14 @@ export type Task = ActiveSubagentCountFields & { status_summary?: TaskStatusSummary | null; /** Explicitly clears a cached status summary. Omission keeps partial-response semantics. */ status_summary_invalidated?: boolean; + /** + * Whether the task's executor profile can be switched right now (nothing has + * materialized yet). Always present on an enriched read; not gap-filled on + * merge (an omitted value reads as ineligible, never as the cached one). + */ + runner_editable?: boolean; + /** Machine-readable reason for `runner_editable`; always present alongside it. */ + runner_ineligible_reason?: string; }; // Task origin values mirror models.TaskOrigin* constants in the Go backend. diff --git a/apps/web/lib/ws/handlers/task-merge.ts b/apps/web/lib/ws/handlers/task-merge.ts index 1a1557e7f4..155a80e98e 100644 --- a/apps/web/lib/ws/handlers/task-merge.ts +++ b/apps/web/lib/ws/handlers/task-merge.ts @@ -88,12 +88,21 @@ function mergeTaskParkedFields( merged.parkedEpoch = existingEpoch; } +// Ordered delivery is not guaranteed, so a client comparing two events must +// ignore one whose updated_at is older than the cached value rather than +// trust arrival order. Missing timestamps on either side skip the check. +function isStaleTaskUpdate(existing: KanbanTask, nextTask: KanbanTask): boolean { + if (!existing.updatedAt || !nextTask.updatedAt) return false; + return new Date(nextTask.updatedAt).getTime() < new Date(existing.updatedAt).getTime(); +} + export function mergeTaskUpdate( existing: KanbanTask | undefined, nextTask: KanbanTask, payload: TaskEventPayload, ): KanbanTask { if (!existing) return nextTask; + if (isStaleTaskUpdate(existing, nextTask)) return existing; const merged = { ...nextTask, ...mergeTaskRepositoryFields(existing, nextTask), diff --git a/apps/web/lib/ws/handlers/tasks-runner-mutability.test.ts b/apps/web/lib/ws/handlers/tasks-runner-mutability.test.ts new file mode 100644 index 0000000000..887cc05e03 --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-runner-mutability.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; +import { makeStore, makeTask, makeMessage } from "./tasks.test-helpers"; + +describe("task.updated runner mutability projection", () => { + // AC-TASKS-RUNNER-SWITCH-001.9: unlike executor identity, an omitted + // runner_editable/runner_ineligible_reason must NOT fall back to the cached + // task's last-known reading — a permission-shaped flag going stale-open is + // worse than going stale-closed. + it("does not gap-fill a cached editable=true when a lightweight update omits the projection", () => { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Old title", + position: 0, + runnerEditable: true, + runnerIneligibleReason: "eligible", + }; + const store = makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + }); + + registerTasksHandlers(store)["task.updated"]!(makeMessage(makeTask("t1", null))); + + const task = store.getState().kanban.tasks.find((item) => item.id === "t1"); + expect(task?.runnerEditable).toBe(false); + expect(task?.runnerIneligibleReason).toBe("evaluation_unavailable"); + }); + + it("carries an explicit projection through to the store", () => { + const store = makeStore({ + kanban: { workflowId: "wf1", steps: [], tasks: [] } as unknown as AppState["kanban"], + }); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask("t1", null), + runner_editable: false, + runner_ineligible_reason: "session_exists", + }), + ); + + const task = store.getState().kanban.tasks.find((item) => item.id === "t1"); + expect(task?.runnerEditable).toBe(false); + expect(task?.runnerIneligibleReason).toBe("session_exists"); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks-update-ordering.test.ts b/apps/web/lib/ws/handlers/tasks-update-ordering.test.ts new file mode 100644 index 0000000000..f1d5bf12e6 --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-update-ordering.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; +import { makeStore, makeTask, makeMessage } from "./tasks.test-helpers"; + +const WORKFLOW_ID = "wf1"; +const TASK_ID = "t1"; +const OLDER_TIMESTAMP = "2026-01-01T00:00:10.000Z"; +const NEWER_TIMESTAMP = "2026-01-01T00:00:20.000Z"; + +// AC-TASKS-RUNNER-SWITCH-002.19: ordered delivery is not guaranteed, so the +// client must compare the incoming event's updated_at against the cached +// value and ignore anything older rather than trust arrival order. +describe("task.updated stale-event ordering guard", () => { + it("ignores an event whose updated_at is older than the cached task", () => { + const existingTask = { + id: TASK_ID, + workflowStepId: "step1", + title: "Current title", + position: 0, + updatedAt: OLDER_TIMESTAMP, + }; + const store = makeStore({ + kanban: { + workflowId: WORKFLOW_ID, + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + }); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask(TASK_ID, null), + title: "Stale title from a late event", + updated_at: "2026-01-01T00:00:05.000Z", + }), + ); + + const task = store.getState().kanban.tasks.find((item) => item.id === TASK_ID); + expect(task?.title).toBe("Current title"); + expect(task?.updatedAt).toBe(OLDER_TIMESTAMP); + }); + + it("applies an event whose updated_at is newer than the cached task", () => { + const existingTask = { + id: TASK_ID, + workflowStepId: "step1", + title: "Current title", + position: 0, + updatedAt: OLDER_TIMESTAMP, + }; + const store = makeStore({ + kanban: { + workflowId: WORKFLOW_ID, + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + }); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask(TASK_ID, null), + title: "Fresh title", + updated_at: NEWER_TIMESTAMP, + }), + ); + + const task = store.getState().kanban.tasks.find((item) => item.id === TASK_ID); + expect(task?.title).toBe("Fresh title"); + expect(task?.updatedAt).toBe(NEWER_TIMESTAMP); + }); + + it("converges on the transaction that committed second regardless of delivery order", () => { + const existingTask = { + id: TASK_ID, + workflowStepId: "step1", + title: "Original title", + position: 0, + }; + const store = makeStore({ + kanban: { + workflowId: WORKFLOW_ID, + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + }); + const handlers = registerTasksHandlers(store); + + // The second switch (committed later) is delivered first... + handlers["task.updated"]!( + makeMessage({ + ...makeTask(TASK_ID, null), + title: "Second switch", + updated_at: NEWER_TIMESTAMP, + }), + ); + // ...then the first switch (committed earlier) arrives late. + handlers["task.updated"]!( + makeMessage({ + ...makeTask(TASK_ID, null), + title: "First switch", + updated_at: OLDER_TIMESTAMP, + }), + ); + + const task = store.getState().kanban.tasks.find((item) => item.id === TASK_ID); + expect(task?.title).toBe("Second switch"); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.test-helpers.ts b/apps/web/lib/ws/handlers/tasks.test-helpers.ts index 0f1f380ac7..8179f6eef5 100644 --- a/apps/web/lib/ws/handlers/tasks.test-helpers.ts +++ b/apps/web/lib/ws/handlers/tasks.test-helpers.ts @@ -98,6 +98,15 @@ export function makeMessage(payload: Record) { } as Parameters["task.updated"]>>[0]; } +export function makeStateChangedMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.state_changed" as const, + payload, + } as Parameters["task.state_changed"]>>[0]; +} + export function makeDeletedMessage(payload: Record) { return { id: "msg-1", diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index 40b19af29a..f93100edf1 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -1,108 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import { registerTasksHandlers } from "./tasks"; - -const SESS_OTHER = "sess-other"; -const SESS_DRIFTED = "sess-drifted"; -const SESS_PINNED = "sess-pinned"; - -type Listener = (state: AppState) => void; - -/** - * Minimal in-memory store for the tasks WS handler tests. - * The handler reads kanban tasks, kanbanMulti snapshots, and tasks.activeTaskId/activeSessionId, - * and calls setActiveSession; everything else can stay default. - */ -function makeStore(initial: Partial = {}) { - let state = { - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - tasks: { - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, - environmentIdBySessionId: {}, - setActiveSession: vi.fn((taskId: string, sessionId: string | null) => { - state = { - ...state, - tasks: { - ...state.tasks, - activeTaskId: taskId, - activeSessionId: sessionId, - pinnedSessionId: sessionId, - lastSessionByTaskId: sessionId - ? { ...state.tasks.lastSessionByTaskId, [taskId]: sessionId } - : state.tasks.lastSessionByTaskId, - }, - }; - }), - setActiveSessionAuto: vi.fn((taskId: string, sessionId: string | null) => { - state = { - ...state, - tasks: { - ...state.tasks, - activeTaskId: taskId, - activeSessionId: sessionId, - }, - }; - }), - removeTaskFromSidebarPrefs: vi.fn(), - setTaskDeletedNotification: vi.fn(), - ...initial, - } as unknown as AppState; - - const listeners = new Set(); - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - const next = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - state = { ...state, ...next }; - for (const l of listeners) l(state); - }, - subscribe: (l: Listener) => { - listeners.add(l); - return () => listeners.delete(l); - }, - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi & { getState: () => AppState }; -} - -function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") { - return { - task_id: id, - workflow_id: workflowId, - workflow_step_id: "step1", - title: "Test", - description: "", - state: "IN_PROGRESS", - primary_session_id: primarySessionId, - is_ephemeral: false, - } as Record; -} - -function makeMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.updated" as const, - payload, - } as Parameters["task.updated"]>>[0]; -} - -function makeStateChangedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.state_changed" as const, - payload, - } as Parameters["task.state_changed"]>>[0]; -} +import { + makeStore, + makeTask, + makeMessage, + makeStateChangedMessage, + SESS_OTHER, + SESS_DRIFTED, + SESS_PINNED, +} from "./tasks.test-helpers"; // Shared setup for the primary-session focus-follow tests: a single task t1 // whose kanban primary, plus the active/pinned session ids, are the only knobs diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 2338211e5d..edd2748e07 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1861,6 +1861,22 @@ "repositorySelectionInvalid": "The selected repository could not be verified.", "repositorySelectionNotFound": "The selected repository was not found.", "repositorySelectionUnavailable": "The repository provider is unavailable. Check the connection and try again.", + "runnerReasonTaskArchived": "The runner can't be changed because this task is archived.", + "runnerReasonNoRepository": "The runner can't be changed because this task has no repository.", + "runnerReasonMultipleRepositories": "The runner can't be changed because this task has more than one repository.", + "runnerReasonSessionExists": "The runner can't be changed because this task already has a session.", + "runnerReasonEnvironmentExists": "The runner can't be changed because this task already has an environment.", + "runnerReasonExecutorRunning": "The runner can't be changed because this task's executor is already running.", + "runnerReasonWorkspaceFolderAttached": "The runner can't be changed because a workspace folder is already attached to this task.", + "runnerReasonWorkspacePathSet": "The runner can't be changed because this task already has a workspace path.", + "runnerReasonWorkspaceGroupMember": "The runner can't be changed because this task shares a workspace with another task.", + "runnerReasonWorkspaceBindingNotIndependent": "The runner can't be changed because this task's workspace binding isn't independent.", + "runnerReasonEvaluationUnavailable": "The runner couldn't be checked. Try again.", + "runnerConflictTargetCannotMaterializeRepository": "That executor can't run this task's repository.", + "runnerSwitchNotFound": "This task could not be found.", + "runnerSwitchInvalid": "That executor profile is not available.", + "runnerSwitchPartiallySaved": "The runner was switched, but the rest of your changes couldn't be saved. Your other edits are still shown below: try saving again.", + "launchFailedAfterTaskSaved": "Your changes were saved, but the agent could not be started. Try starting it again.", "failedToCreateFile": "Failed to create file", "failedToCreateSession": "Failed to create session", "failedToCreateTask": "Failed to create task", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index da30ea6739..42efc150fb 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1861,6 +1861,22 @@ "repositorySelectionInvalid": "Ţĥē śēĺēćţēď ŕēƥōśĩţōŕŷ ćōũĺď ńōţ ƀē vēŕĩƒĩēď.", "repositorySelectionNotFound": "Ţĥē śēĺēćţēď ŕēƥōśĩţōŕŷ ŵàś ńōţ ƒōũńď.", "repositorySelectionUnavailable": "Ţĥē ŕēƥōśĩţōŕŷ ƥŕōvĩďēŕ ĩś ũńàvàĩĺàƀĺē. Ćĥēćķ ţĥē ćōńńēćţĩōń àńď ţŕŷ àĝàĩń.", + "runnerReasonTaskArchived": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ ĩś àŕćĥĩvēď.", + "runnerReasonNoRepository": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ ĥàś ńō ŕēƥōśĩţōŕŷ.", + "runnerReasonMultipleRepositories": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ ĥàś ḿōŕē ţĥàń ōńē ŕēƥōśĩţōŕŷ.", + "runnerReasonSessionExists": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ àĺŕēàďŷ ĥàś à śēśśĩōń.", + "runnerReasonEnvironmentExists": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ àĺŕēàďŷ ĥàś àń ēńvĩŕōńḿēńţ.", + "runnerReasonExecutorRunning": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ'ś ēxēćũţōŕ ĩś àĺŕēàďŷ ŕũńńĩńĝ.", + "runnerReasonWorkspaceFolderAttached": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē à ŵōŕķśƥàćē ƒōĺďēŕ ĩś àĺŕēàďŷ àţţàćĥēď ţō ţĥĩś ţàśķ.", + "runnerReasonWorkspacePathSet": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ àĺŕēàďŷ ĥàś à ŵōŕķśƥàćē ƥàţĥ.", + "runnerReasonWorkspaceGroupMember": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ śĥàŕēś à ŵōŕķśƥàćē ŵĩţĥ àńōţĥēŕ ţàśķ.", + "runnerReasonWorkspaceBindingNotIndependent": "Ţĥē ŕũńńēŕ ćàń'ţ ƀē ćĥàńĝēď ƀēćàũśē ţĥĩś ţàśķ'ś ŵōŕķśƥàćē ƀĩńďĩńĝ ĩśń'ţ ĩńďēƥēńďēńţ.", + "runnerReasonEvaluationUnavailable": "Ţĥē ŕũńńēŕ ćōũĺďń'ţ ƀē ćĥēćķēď. Ţŕŷ àĝàĩń.", + "runnerConflictTargetCannotMaterializeRepository": "Ţĥàţ ēxēćũţōŕ ćàń'ţ ŕũń ţĥĩś ţàśķ'ś ŕēƥōśĩţōŕŷ.", + "runnerSwitchNotFound": "Ţĥĩś ţàśķ ćōũĺď ńōţ ƀē ƒōũńď.", + "runnerSwitchInvalid": "Ţĥàţ ēxēćũţōŕ ƥŕōƒĩĺē ĩś ńōţ àvàĩĺàƀĺē.", + "runnerSwitchPartiallySaved": "Ţĥē ŕũńńēŕ ŵàś śŵĩţćĥēď, ƀũţ ţĥē ŕēśţ ōƒ ŷōũŕ ćĥàńĝēś ćōũĺďń'ţ ƀē śàvēď. Ŷōũŕ ōţĥēŕ ēďĩţś àŕē śţĩĺĺ śĥōŵń ƀēĺōŵ: ţŕŷ śàvĩńĝ àĝàĩń.", + "launchFailedAfterTaskSaved": "Ŷōũŕ ćĥàńĝēś ŵēŕē śàvēď, ƀũţ ţĥē àĝēńţ ćōũĺď ńōţ ƀē śţàŕţēď. Ţŕŷ śţàŕţĩńĝ ĩţ àĝàĩń.", "failedToCreateFile": "Ƒàĩĺēď ţō ćŕēàţē ƒĩĺē", "failedToCreateSession": "Ƒàĩĺēď ţō ćŕēàţē śēśśĩōń", "failedToCreateTask": "Ƒàĩĺēď ţō ćŕēàţē ţàśķ", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 4b4a1e8f65..f2e5ddb41f 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1804,6 +1804,22 @@ "repositorySelectionInvalid": "Não foi possível verificar o repositório selecionado.", "repositorySelectionNotFound": "O repositório selecionado não foi encontrado.", "repositorySelectionUnavailable": "O fornecedor do repositório não está disponível. Verifique a ligação e tente novamente.", + "runnerReasonTaskArchived": "Não é possível alterar o perfil de executor porque esta tarefa está arquivada.", + "runnerReasonNoRepository": "Não é possível alterar o perfil de executor porque esta tarefa não tem repositório.", + "runnerReasonMultipleRepositories": "Não é possível alterar o perfil de executor porque esta tarefa tem mais do que um repositório.", + "runnerReasonSessionExists": "Não é possível alterar o perfil de executor porque esta tarefa já tem uma sessão.", + "runnerReasonEnvironmentExists": "Não é possível alterar o perfil de executor porque esta tarefa já tem um ambiente.", + "runnerReasonExecutorRunning": "Não é possível alterar o perfil de executor porque o executor desta tarefa já está em execução.", + "runnerReasonWorkspaceFolderAttached": "Não é possível alterar o perfil de executor porque já existe uma pasta de workspace associada a esta tarefa.", + "runnerReasonWorkspacePathSet": "Não é possível alterar o perfil de executor porque esta tarefa já tem um caminho de workspace.", + "runnerReasonWorkspaceGroupMember": "Não é possível alterar o perfil de executor porque esta tarefa partilha um workspace com outra tarefa.", + "runnerReasonWorkspaceBindingNotIndependent": "Não é possível alterar o perfil de executor porque a associação de workspace desta tarefa não é independente.", + "runnerReasonEvaluationUnavailable": "Não foi possível verificar o perfil de executor. Tente novamente.", + "runnerConflictTargetCannotMaterializeRepository": "Esse executor não consegue executar o repositório desta tarefa.", + "runnerSwitchNotFound": "Esta tarefa não foi encontrada.", + "runnerSwitchInvalid": "Esse perfil de executor não está disponível.", + "runnerSwitchPartiallySaved": "O perfil de executor foi alterado, mas o resto das suas alterações não pôde ser guardado. As suas outras edições continuam visíveis abaixo: tente guardar novamente.", + "launchFailedAfterTaskSaved": "As suas alterações foram guardadas, mas não foi possível iniciar o agente. Tente iniciá-lo novamente.", "failedToCreateFile": "Falha ao criar o ficheiro", "failedToCreateSession": "Falha ao criar a sessão", "failedToCreateTask": "Falha ao criar a tarefa", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index e47384cb5c..c00cc3b3e3 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1803,6 +1803,22 @@ "repositorySelectionInvalid": "无法验证所选仓库。", "repositorySelectionNotFound": "找不到所选仓库。", "repositorySelectionUnavailable": "仓库提供商不可用。请检查连接后重试。", + "runnerReasonTaskArchived": "无法更改执行器配置,因为该任务已归档。", + "runnerReasonNoRepository": "无法更改执行器配置,因为该任务没有仓库。", + "runnerReasonMultipleRepositories": "无法更改执行器配置,因为该任务包含多个仓库。", + "runnerReasonSessionExists": "无法更改执行器配置,因为该任务已存在会话。", + "runnerReasonEnvironmentExists": "无法更改执行器配置,因为该任务已存在环境。", + "runnerReasonExecutorRunning": "无法更改执行器配置,因为该任务的执行器已在运行。", + "runnerReasonWorkspaceFolderAttached": "无法更改执行器配置,因为该任务已附加工作区文件夹。", + "runnerReasonWorkspacePathSet": "无法更改执行器配置,因为该任务已设置工作区路径。", + "runnerReasonWorkspaceGroupMember": "无法更改执行器配置,因为该任务与另一个任务共享工作区。", + "runnerReasonWorkspaceBindingNotIndependent": "无法更改执行器配置,因为该任务的工作区绑定不是独立的。", + "runnerReasonEvaluationUnavailable": "无法检查执行器配置,请重试。", + "runnerConflictTargetCannotMaterializeRepository": "该执行器配置无法运行此任务的仓库。", + "runnerSwitchNotFound": "找不到此任务。", + "runnerSwitchInvalid": "该执行器配置不可用。", + "runnerSwitchPartiallySaved": "执行器配置已更改,但其余更改未能保存。您的其他编辑仍显示在下方,请重试保存。", + "launchFailedAfterTaskSaved": "您的更改已保存,但无法启动代理。请重试启动。", "failedToCreateFile": "创建文件失败", "failedToCreateSession": "创建会话失败", "failedToCreateTask": "创建任务失败", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index f6bec76ca8..a433cbb47e 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1803,6 +1803,22 @@ "repositorySelectionInvalid": "無法驗證所選儲存庫。", "repositorySelectionNotFound": "找不到所選儲存庫。", "repositorySelectionUnavailable": "儲存庫提供者不可用。請檢查連接後重試。", + "runnerReasonTaskArchived": "無法更改執行器設定檔,因為該任務已歸檔。", + "runnerReasonNoRepository": "無法更改執行器設定檔,因為該任務沒有儲存庫。", + "runnerReasonMultipleRepositories": "無法更改執行器設定檔,因為該任務包含多個儲存庫。", + "runnerReasonSessionExists": "無法更改執行器設定檔,因為該任務已存在工作階段。", + "runnerReasonEnvironmentExists": "無法更改執行器設定檔,因為該任務已存在環境。", + "runnerReasonExecutorRunning": "無法更改執行器設定檔,因為該任務的執行器已在執行。", + "runnerReasonWorkspaceFolderAttached": "無法更改執行器設定檔,因為該任務已附加工作區資料夾。", + "runnerReasonWorkspacePathSet": "無法更改執行器設定檔,因為該任務已設定工作區路徑。", + "runnerReasonWorkspaceGroupMember": "無法更改執行器設定檔,因為該任務與另一個任務共享工作區。", + "runnerReasonWorkspaceBindingNotIndependent": "無法更改執行器設定檔,因為該任務的工作區綁定不是獨立的。", + "runnerReasonEvaluationUnavailable": "無法檢查執行器設定檔,請重試。", + "runnerConflictTargetCannotMaterializeRepository": "該執行器設定檔無法執行此任務的儲存庫。", + "runnerSwitchNotFound": "找不到此任務。", + "runnerSwitchInvalid": "該執行器設定檔不可用。", + "runnerSwitchPartiallySaved": "執行器設定檔已更改,但其餘更改未能儲存。您的其他編輯仍顯示在下方,請重試儲存。", + "launchFailedAfterTaskSaved": "您的更改已儲存,但無法啓動代理。請重試啓動。", "failedToCreateFile": "建立檔案失敗", "failedToCreateSession": "建立工作階段失敗", "failedToCreateTask": "建立任務失敗", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 633f0029cb..65e811de45 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1803,6 +1803,22 @@ "repositorySelectionInvalid": "無法驗證所選儲存庫。", "repositorySelectionNotFound": "找不到所選儲存庫。", "repositorySelectionUnavailable": "儲存庫提供者不可用。請檢查連線後重試。", + "runnerReasonTaskArchived": "無法更改執行器設定檔,因為該任務已歸檔。", + "runnerReasonNoRepository": "無法更改執行器設定檔,因為該任務沒有儲存庫。", + "runnerReasonMultipleRepositories": "無法更改執行器設定檔,因為該任務包含多個儲存庫。", + "runnerReasonSessionExists": "無法更改執行器設定檔,因為該任務已存在工作階段。", + "runnerReasonEnvironmentExists": "無法更改執行器設定檔,因為該任務已存在環境。", + "runnerReasonExecutorRunning": "無法更改執行器設定檔,因為該任務的執行器已在執行。", + "runnerReasonWorkspaceFolderAttached": "無法更改執行器設定檔,因為該任務已附加工作區資料夾。", + "runnerReasonWorkspacePathSet": "無法更改執行器設定檔,因為該任務已設定工作區路徑。", + "runnerReasonWorkspaceGroupMember": "無法更改執行器設定檔,因為該任務與另一個任務共享工作區。", + "runnerReasonWorkspaceBindingNotIndependent": "無法更改執行器設定檔,因為該任務的工作區繫結不是獨立的。", + "runnerReasonEvaluationUnavailable": "無法檢查執行器設定檔,請重試。", + "runnerConflictTargetCannotMaterializeRepository": "該執行器設定檔無法執行此任務的儲存庫。", + "runnerSwitchNotFound": "找不到此任務。", + "runnerSwitchInvalid": "該執行器設定檔不可用。", + "runnerSwitchPartiallySaved": "執行器設定檔已更改,但其餘更改未能儲存。您的其他編輯仍顯示在下方,請重試儲存。", + "launchFailedAfterTaskSaved": "您的更改已儲存,但無法啟動代理。請重試啟動。", "failedToCreateFile": "建立檔案失敗", "failedToCreateSession": "建立工作階段失敗", "failedToCreateTask": "建立任務失敗", diff --git a/docs/plans/runner-switch-before-materialization/plan.md b/docs/plans/runner-switch-before-materialization/plan.md new file mode 100644 index 0000000000..a5eecc050a --- /dev/null +++ b/docs/plans/runner-switch-before-materialization/plan.md @@ -0,0 +1,38 @@ +--- +created: 2026-09-07 +status: completed +requirements: + - REQ-TASKS-RUNNER-SWITCH-001 + - REQ-TASKS-RUNNER-SWITCH-002 + - REQ-TASKS-RUNNER-SWITCH-003 + - REQ-TASKS-RUNNER-SWITCH-004 +system_design: + - ../../specs/tasks/system-design/runner-switch-before-materialization.md +--- + +# Implementation Plan: Runner Switch Before Materialization + +## Overview + +Allow a task's executor profile to change while the task has no materialized +runtime state. Keep the mutability verdict in the task service, validate target +runner compatibility before persistence, and serialize the switch against every +writer that can materialize the task. The existing task dialog uses the same +server-owned verdict on desktop and mobile. + +The implementation keeps the stored runner as task metadata. A successful +switch changes only that value and publishes the existing task update. The next +session preparation resolves the committed profile, while explicit launch +overrides remain explicit. + +## Delivery + +- Project the ordered runner mutability verdict through every task projection. +- Add the task runner action with compatibility validation and typed outcomes. +- Lock all materialization and repository writers against concurrent switches. +- Wire the dialog save sequence and runner selection state to the action. +- Cover the backend races, PostgreSQL lock order, and partial-save retry flow. + +## Work orders + +- [Task 01: Runner switch before materialization](task-01-runner-switch-before-materialization.md) diff --git a/docs/plans/runner-switch-before-materialization/task-01-runner-switch-before-materialization.md b/docs/plans/runner-switch-before-materialization/task-01-runner-switch-before-materialization.md new file mode 100644 index 0000000000..b96ab22ad8 --- /dev/null +++ b/docs/plans/runner-switch-before-materialization/task-01-runner-switch-before-materialization.md @@ -0,0 +1,52 @@ +--- +id: "01-runner-switch-before-materialization" +title: "Implement runner switch before materialization" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-RUNNER-SWITCH-001 + - REQ-TASKS-RUNNER-SWITCH-002 + - REQ-TASKS-RUNNER-SWITCH-003 + - REQ-TASKS-RUNNER-SWITCH-004 +acceptance_criteria: + - AC-TASKS-RUNNER-SWITCH-001.1 + - AC-TASKS-RUNNER-SWITCH-002.3 + - AC-TASKS-RUNNER-SWITCH-003.1 + - AC-TASKS-RUNNER-SWITCH-004.1 +system_design: + - ../../specs/tasks/system-design/runner-switch-before-materialization.md +--- + +# Task 01: Runner switch before materialization + +## Acceptance + +- Task projections carry the server-derived runner mutability verdict and its + closed reason vocabulary. +- The runner action rechecks mutability under the task lock, validates the + target runner, and changes only the stored executor profile. +- Session preparation and every materialization writer observe the committed + runner in a deterministic order. +- The task dialog applies a successful runner switch before later save calls, + keeps the last confirmed value after a partial failure, and uses the same + behavior on desktop and mobile. + +## Verification + +```bash +go test ./internal/orchestrator/executor ./internal/task/repository/sqlite -count=1 +go test -race ./internal/orchestrator/executor -run 'TestPrepareSessionRetriesTaskRunnerChangedAfterReload|TestEnsureSessionForAgentRetriesTaskRunnerChangedAfterReload' -count=1 +go test -race ./internal/task/repository/sqlite -run 'TestCreateTaskSessionRejectsStaleTaskRunnerResolution|TestSwitchTaskRunner_ConcurrentBaseBranchUpdateNeverBlendsWithSwitch|TestPostgresRepositoryWritersUseConsistentTaskThenLinkLockOrder' -count=1 +cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-submit.test.tsx +``` + +## Implementation result + +The backend stores transient runner-resolution metadata on a session and +retries task-derived preparation when a concurrent switch commits first. The +SQLite session transaction rejects stale resolutions. Comparison-target writes +use task-before-link locking and recheck ownership. The web dialog tracks the +last confirmed runner so a failed later save can be retried in either direction. + diff --git a/docs/specs/tasks/requirements/runner-switch-before-materialization-action.md b/docs/specs/tasks/requirements/runner-switch-before-materialization-action.md new file mode 100644 index 0000000000..00e13460ce --- /dev/null +++ b/docs/specs/tasks/requirements/runner-switch-before-materialization-action.md @@ -0,0 +1,266 @@ +--- +status: draft +system: tasks +created: 2026-09-07 +owners: + - kandev +--- + +# Runner Switch Before Materialization Action Requirements + +## Overview + +The runner switch action itself: how a runner change is authorized, serialized, +applied, and refused. The mutability rule the action enforces is in +[Runner switch before materialization](runner-switch-before-materialization.md); +what a successful switch changes, and the surface a user drives it from, are in +[Runner switch before materialization effects](runner-switch-before-materialization-effects.md). + +This file exists because the parts together exceed the requirement file-size +limit. The split is by layer, not by importance: every acceptance criterion +keeps the identifier it had when they were one document. + +## Requirements + +### REQ-TASKS-RUNNER-SWITCH-002: Runner switch action + +**Intent:** Changing a task's runner is one authorized, atomic, retry-safe +operation that either moves the runner or explains precisely why it cannot, +and that never damages the rest of the task's metadata. + +#### Acceptance criteria + +- **AC-TASKS-RUNNER-SWITCH-002.1:** The system shall expose a task action named + `task.runner` accepting a payload with `id` naming the task and + `executor_profile_id` naming the target runner. The action name shall consist + of the task namespace plus exactly one further segment, so that the existing + authorization backstop for top-level task actions applies and promotes `id` + to the task identifier. +- **AC-TASKS-RUNNER-SWITCH-002.2:** The system shall authorize the caller against + the named task before evaluating either gate, at the position + AC-TASKS-RUNNER-SWITCH-002.17 fixes. This authorization shall run for every + caller on every transport, including the WebSocket gateway. The gateway-level + backstop is an additional layer for that one transport and shall never be the + only authorization for the operation: the service shall not vary its + authorization by the transport a request arrived on, and shall not depend on + being able to determine that transport. +- **AC-TASKS-RUNNER-SWITCH-002.3:** When the action executes, the system shall + re-evaluate every condition of AC-TASKS-RUNNER-SWITCH-001.3 inside a single + transaction that is serialized against session creation and task lifecycle + cleanup for the same task. +- **AC-TASKS-RUNNER-SWITCH-002.3a:** The serialization rule is general and is + stated once, because enumerating a subset of the conditions is what lets the + remainder race. **Every writer that can cause any condition of + AC-TASKS-RUNNER-SWITCH-001.3 to begin holding for a task, or that can change + which repository the compatibility gate of AC-TASKS-RUNNER-SWITCH-002.7 + resolves against, shall take the same task-scoped lock the switch takes.** The + second clause is not a separate rule: a verdict computed against a repository + the task no longer has is as wrong as a gate condition that began holding + unobserved, and a writer that swaps a repository link in place changes no row + count, so the first clause alone would not reach it. No condition is exempt on + the ground that its row is reached through a session: none of the rows the gate + reads is keyed to a session, and a condition that is only observed rather than + serialized is not enforceable. The guarantee + AC-TASKS-RUNNER-SWITCH-002.13 gives for sessions shall hold identically for + every writer in scope: the switch commits first and the writer proceeds against + the new runner, or the writer commits first and the switch is refused with that + condition's code. A writer added later that can make a gate condition hold, or + can change the resolved repository, falls under this criterion without it being + amended. +- **AC-TASKS-RUNNER-SWITCH-002.3b:** The set of writers in scope for + AC-TASKS-RUNNER-SWITCH-002.3a is finite, is determined by audit rather than by + assumption, and shall be enumerated so the obligation is bounded and testable + rather than left for whoever implements it to discover. A writer satisfies the + rule in one of two ways, and the distinction is what bounds the work: + + 1. **Already satisfied, no change required** — the writer updates the task's own + row, and so takes that row's lock inherently, or takes the task-scoped lock + explicitly. Archiving the task, writing the task's host workspace path, and + writing the task's workspace mode are all writes to the task row. Session + creation and task environment creation take the task-scoped lock explicitly. + 2. **Requires the lock to be added** — the writer writes a different table and + takes no task-scoped lock today. This capability shall bring each of these + under the same lock: the running-executor record write (condition 6); the + workspace source attachment that creates repository links and workspace + folder attachments (conditions 2, 3 and 7), which today takes the lock only + when it is also checking an expected parent and therefore takes none at all + for a task with no parent — the shape this capability most commonly serves; + the workspace group membership insert (condition 9), which is owned by the + office system rather than the task system; and the in-place update of an + existing task-repository link, which is in scope under the second clause of + AC-TASKS-RUNNER-SWITCH-002.3a. + + Each writer in class 2 shall have its own acceptance evidence: a demonstration + that it and a concurrent switch produce one of the two outcomes + AC-TASKS-RUNNER-SWITCH-002.3a permits and never a third. For the three writers + that make a gate condition begin holding, those two outcomes are the ones that + criterion names: switch first and the writer proceeds against the new runner, or + writer first and the switch is refused with that condition's code. The in-place + link update has no condition code, because it makes no condition hold; its two + outcomes are that the switch commits first and the link update then applies, or + the link update commits first and the switch is refused as the retriable + `evaluation_unavailable` of AC-TASKS-RUNNER-SWITCH-002.7c. Both are outcomes the + closed vocabulary already contains; this criterion introduces none. A writer in class 1 + shall have the same evidence, but needs no code change to produce it. An + implementation that changes no writer cannot satisfy this criterion, and one + that reports the rule as met without per-writer evidence has not met it. +- **AC-TASKS-RUNNER-SWITCH-002.4:** When a switch succeeds, the only task + metadata that changes shall be the stored executor profile identifier. Every + other metadata entry, including the agent profile identifier and any deferred + launch record, shall be unchanged afterwards. +- **AC-TASKS-RUNNER-SWITCH-002.5:** When a switch is rejected for any reason, + the system shall persist no change to the task. +- **AC-TASKS-RUNNER-SWITCH-002.6:** When a switch is rejected because the + mutability gate fails, the system shall return a typed conflict carrying the + same reason code, chosen by the same ordering, that + AC-TASKS-RUNNER-SWITCH-001.3 defines. +- **AC-TASKS-RUNNER-SWITCH-002.7:** When the mutability gate passes but the + target runner requires a git clone URL and the task's repository provides + none, the system shall reject the switch with the typed conflict + `target_cannot_materialize_repository`. This code shall never appear in the + projection defined by REQ-TASKS-RUNNER-SWITCH-001, because it describes the + target rather than the task. +- **AC-TASKS-RUNNER-SWITCH-002.7a:** Clone-URL resolution shall distinguish a + determinate answer from a failed lookup. A candidate that is absent, empty, or + whitespace-only supplies no URL and resolution continues to the next candidate. + Only an exhausted candidate list shall reject with + `target_cannot_materialize_repository`. A candidate that cannot be evaluated, + because the lookup errored or timed out, shall reject with + `evaluation_unavailable` per AC-TASKS-RUNNER-SWITCH-002.18. A transient failure + to read a repository's origin shall never be reported as a permanent statement + that the repository cannot be moved. +- **AC-TASKS-RUNNER-SWITCH-002.7b:** The compatibility gate shall not be + evaluated while the task row lock is held. The target profile's executor, its + clone-URL requirement, and any repository clone URL shall be resolved before + the transaction of AC-TASKS-RUNNER-SWITCH-002.3 opens, so no filesystem or + subprocess call occurs inside the locked section. Resolution shall be bounded + by a timeout; exceeding it is a failed lookup under + AC-TASKS-RUNNER-SWITCH-002.7a. What may be resolved early is only what the + filesystem or a subprocess is needed for; which repository the task has is a + task-scoped fact and is governed by AC-TASKS-RUNNER-SWITCH-002.7c, not by this + criterion. Resolving early does not move the decision: the + resolved result is applied at stage 6 of AC-TASKS-RUNNER-SWITCH-002.17, so a + task that fails the mutability gate reports that gate's code even when the + target is also incompatible. +- **AC-TASKS-RUNNER-SWITCH-002.7c:** A compatibility verdict is only valid for + the repository it was computed against, because it is resolved before the + transaction that validates which repository the task has. The system shall + record the identity of the repository the resolution used, and inside the + transaction, after the mutability gate has passed, shall confirm that the + task's single attached repository is still that same repository. This + confirmation shall compare both the repository identity and the link's + last-updated column, `task_repositories.updated_at`, against the values + observed at resolution time, so that a link mutated in place is detected even + though its identity column may not have moved. This confirmation is a + task-scoped read of two columns and involves no filesystem or subprocess call, + so it does not violate AC-TASKS-RUNNER-SWITCH-002.7b. It is a second line of + defence rather than the primary one: the writer that mutates the link is + itself in scope for AC-TASKS-RUNNER-SWITCH-002.3a, and the confirmation exists + so that a writer added outside that rule is still caught. When the repository has changed, the resolved + verdict shall be discarded and the request shall reject as + `evaluation_unavailable` under AC-TASKS-RUNNER-SWITCH-002.18, which is + retriable, rather than applying a verdict computed against a repository the + task no longer has. When the task does not have exactly one repository + attachment at resolution time, resolution shall be skipped and shall yield no + verdict; the request then reports `no_repository` or `multiple_repositories` + from the mutability gate at stage 5, and skipping shall never itself produce an + outcome. This keeps the ordering of AC-TASKS-RUNNER-SWITCH-002.17 intact: the + compatibility gate can only ever speak at stage 6. +- **AC-TASKS-RUNNER-SWITCH-002.8:** An identifier in this payload is **blank** + when it is absent, empty, or contains only whitespace. That single definition + governs every identifier the action accepts, so `id` and `executor_profile_id` + are judged by the same test and a whitespace-only value is never treated as a + value that could be looked up. When either identifier is blank the system shall + reject the request as invalid. A blank `executor_profile_id` shall never be + interpreted as clearing the task's runner, and a blank `id` shall be reported + as invalid rather than as not found. +- **AC-TASKS-RUNNER-SWITCH-002.9:** When the payload names a task that does not + exist, the system shall reject the request as not found, distinctly from every + conflict code. +- **AC-TASKS-RUNNER-SWITCH-002.10:** When the payload names an executor profile + that does not exist, or one whose executor is not available for use, the + system shall reject the request as invalid, distinctly from every conflict + code. An executor is available for use when it is not soft-deleted **and** its + status is active; failing either test makes it unavailable, and both produce + the same invalid outcome. +- **AC-TASKS-RUNNER-SWITCH-002.11:** When a switch names the executor profile + the task already stores, the system shall report success, shall leave the + stored value unchanged, and shall publish a task-updated event only when the + stored value actually changed. This success is reported only after both gates + have passed; naming the already-stored profile shall not bypass either. A task + that has since materialized therefore receives the gate's conflict code, not + success, even when the requested profile equals the stored one. +- **AC-TASKS-RUNNER-SWITCH-002.12:** When two switches for the same task run + concurrently, the system shall serialize them on the task. Both may succeed; + the stored runner afterwards shall be the one written by the transaction that + committed second, and no intermediate or blended metadata state shall be + observable. +- **AC-TASKS-RUNNER-SWITCH-002.13:** When a switch and a session creation for + the same task run concurrently, the system shall produce exactly one of two + outcomes: the switch commits first and the session is then prepared with the + new runner, or the session commits first and the switch is rejected with + `session_exists`. The system shall never leave a changed runner alongside a + session that was prepared under the previous runner. +- **AC-TASKS-RUNNER-SWITCH-002.14:** When a caller repeats the same switch after + an ambiguous failure, without supplying any idempotency token, and the task's + materialization state has not changed in between, the system shall reach the + same terminal state as a single successful call. The action assigns a value + rather than accumulating one, so retries need no deduplication key. When the + task did materialize in between, the repeat is refused by the gate per + AC-TASKS-RUNNER-SWITCH-002.11; the stored runner remains the one the first call + committed, so only the reported outcome differs. + +- **AC-TASKS-RUNNER-SWITCH-002.15:** When a switch and an archive of the same + task run concurrently, the system shall serialize them: either the switch + commits before the archive, or the switch is rejected with `task_archived`. A + switch shall never commit against an archived task. +- **AC-TASKS-RUNNER-SWITCH-002.16:** The comparison in + AC-TASKS-RUNNER-SWITCH-002.11 shall be against the value the task stores, not + against the runner the task would otherwise resolve. Storing an explicit + executor profile on a task that previously stored none is a change even when + the named profile is the one the workspace default would have selected. +- **AC-TASKS-RUNNER-SWITCH-002.17:** When more than one rejection class applies + to the same request, the system shall report the outcome of the **first** + failing stage in exactly this order, so that a given request always produces + the same outcome regardless of implementation: + + 1. **Malformed request** (invalid): `id` or `executor_profile_id` blank in the + sense AC-TASKS-RUNNER-SWITCH-002.8 defines. Evaluated before any lookup, so + a blank identifier never reaches the not-found or target-invalid stages. + 2. **Task not found** per AC-TASKS-RUNNER-SWITCH-002.9. + 3. **Caller not authorized** per AC-TASKS-RUNNER-SWITCH-002.2, after existence + so the outcome matches the other top-level task actions. + 4. **Target invalid** per AC-TASKS-RUNNER-SWITCH-002.10. + 5. **Mutability gate** per AC-TASKS-RUNNER-SWITCH-002.6, itself ordered by + AC-TASKS-RUNNER-SWITCH-001.3. + 6. **Compatibility gate** per AC-TASKS-RUNNER-SWITCH-002.7. + 7. **Assignment**, including the no-op of AC-TASKS-RUNNER-SWITCH-002.11. + + An evaluation failure under AC-TASKS-RUNNER-SWITCH-002.18 may arise at stages + 2 and 4 through 7 and preempts the outcome of the stage where it occurs. Stage + 7 is included because that criterion covers the write and the commit, not only + the reads: a request that passed every gate and then failed to commit reports + the retriable outcome rather than a success or a conflict. +- **AC-TASKS-RUNNER-SWITCH-002.18:** When the system cannot complete an + operation it needs in order to decide or to apply the request, the action shall + reject with a typed `evaluation_unavailable` outcome, distinct from every + mutability conflict code, from `target_cannot_materialize_repository`, and from + the invalid and not-found outcomes. It shall be marked retriable and shall + persist no change per AC-TASKS-RUNNER-SWITCH-002.5. **This covers writes and + not only reads:** a failure to acquire the task-scoped lock, a failure of the + metadata write, and a failure to commit the transaction all produce this + outcome, exactly as a failed read does. Naming only reads would leave the write + path with no outcome class at all, and the caller would receive something + outside the vocabulary AC-TASKS-RUNNER-SWITCH-004.4b is required to present. + Such a failure shall never be reported as a mutability conflict: a conflict + says the task is ineligible, a failed operation only that the system could not + complete it. A caller that receives it may repeat the request under + AC-TASKS-RUNNER-SWITCH-002.14, which is why this outcome and only this outcome + is retriable. +- **AC-TASKS-RUNNER-SWITCH-002.19:** A task-updated event for a runner change + shall carry the task's updated-at value from the committing transaction, and a + client shall ignore an event whose updated-at is older than the value it holds + for that task. Clients receiving two switches' events out of order shall + converge on the value written by the transaction that committed second, per + AC-TASKS-RUNNER-SWITCH-002.12. Ordered delivery is not required; a late event + must not overwrite a newer one. diff --git a/docs/specs/tasks/requirements/runner-switch-before-materialization-effects.md b/docs/specs/tasks/requirements/runner-switch-before-materialization-effects.md new file mode 100644 index 0000000000..f2719da8ef --- /dev/null +++ b/docs/specs/tasks/requirements/runner-switch-before-materialization-effects.md @@ -0,0 +1,169 @@ +--- +status: draft +system: tasks +created: 2026-09-07 +owners: + - kandev +--- + +# Runner Switch Before Materialization Effects Requirements + +## Overview + +What a runner switch changes, and how a user drives it. The decision contract +itself is split across two neighbours: the mutability projection is in +[Runner switch before materialization](runner-switch-before-materialization.md) +and the switch action is in +[Runner switch before materialization action](runner-switch-before-materialization-action.md). + +This file exists because the parts together exceed the requirement file-size +limit. The split is by layer, not by importance: every acceptance criterion +keeps the identifier it had when they were one document. + +## Requirements + +### REQ-TASKS-RUNNER-SWITCH-003: Effect of a switch on the next launch + +**Intent:** A successful switch is observable in exactly one place, the next +session the task prepares, and nowhere else. + +#### Acceptance criteria + +- **AC-TASKS-RUNNER-SWITCH-003.1:** When a task's runner has been switched and + the task then prepares its first session, the system shall bind that session + to the executor profile named by the switch and to the executor that profile + belongs to. Some launch paths name an executor profile explicitly in the launch + request rather than leaving it to be read from the task; the task edit dialog is + one of them. Where a launch is issued by the same interaction that performed the + switch, the profile it names shall be the switched-to profile, so that an + explicitly named profile can never disagree with the value the switch just + committed. A launch issued by some other caller that names a profile explicitly + is unchanged by this capability and continues to use the profile it names; this + criterion constrains only the launch that shares an interaction with a switch, + because that is the only one whose disagreement the switch itself would cause. +- **AC-TASKS-RUNNER-SWITCH-003.2:** When a switch succeeds, the system shall not + create, modify, or remove any session, task environment, worktree, container, + or running-executor record. +- **AC-TASKS-RUNNER-SWITCH-003.3:** When a switch changes the stored runner, the + system shall publish a task-updated event whose payload carries both the new + executor profile and the recomputed values required by + AC-TASKS-RUNNER-SWITCH-001.1. + +- **AC-TASKS-RUNNER-SWITCH-003.3a:** Publication of that event is best-effort and + is not part of the committed transaction. A publication that fails shall not + roll back, retry, or otherwise alter the committed switch, and shall not be + reported to the caller as a failed switch; the switch succeeded and the stored + runner is the new one. Because a repeat of the request is a no-op under + AC-TASKS-RUNNER-SWITCH-002.11 and therefore publishes nothing, the event shall + not be the only way a client can learn the new value: any subsequent read of + the task shall carry the committed runner and the recomputed verdict, so a + client that missed the event converges on its next read of that task rather + than staying wrong until something unrelated refreshes it. No durable outbox, + replay log, or delivery receipt is required by this contract. +- **AC-TASKS-RUNNER-SWITCH-003.4:** When the task's workflow step pins an agent + profile, that pin shall continue to govern the agent profile only. It shall not + override the executor profile a switch stored. +- **AC-TASKS-RUNNER-SWITCH-003.5:** When a task with a parent has a stored + executor profile because of a switch, the launch shall use that profile and + shall not fall back to inheriting the parent's runner. + +### REQ-TASKS-RUNNER-SWITCH-004: Runner editing surface + +**Intent:** The runner picker that already exists in the task dialog becomes +effective when editing a task, and it is gated by the server's verdict rather +than by a client-side approximation of it. + +#### Acceptance criteria + +- **AC-TASKS-RUNNER-SWITCH-004.1:** When a user edits a task whose projected + `runner_editable` is `true`, the task dialog shall offer the executor profile + selector, and saving a changed selection shall perform the runner switch. +- **AC-TASKS-RUNNER-SWITCH-004.2:** When a user edits a task whose projected + `runner_editable` is `false`, the dialog shall not offer the selector for + editing and shall present the projected reason to the user. +- **AC-TASKS-RUNNER-SWITCH-004.3:** The dialog shall decide whether the runner + is editable only from the projected verdict. It shall not decide editability + from the task's workflow state, and the previous state-only gate shall no + longer govern the runner selector. +- **AC-TASKS-RUNNER-SWITCH-004.4:** When a switch is rejected, the dialog shall + remain open, shall present the returned reason, and shall not show the + rejected selection as if it had been applied. +- **AC-TASKS-RUNNER-SWITCH-004.4a:** When one save changes the executor profile + as well as other task fields, the dialog shall issue the runner switch **first** + and the remaining updates only after it succeeds. When the switch is rejected + the dialog shall issue no other update, shall leave every other edited field + unsaved and still populated, and shall report that nothing was saved. A + rejected switch shall never leave part of the save applied. +- **AC-TASKS-RUNNER-SWITCH-004.4c:** The save is an ordered sequence of + independent calls and is not atomic, so the rule covers the whole sequence + rather than only its first step. When any call fails, the dialog shall stop at + that call and issue none of the calls after it; it shall leave the fields those + later calls would have saved unsaved and still populated; and it shall report + which part of the save was applied and which was not, rather than reporting a + single undifferentiated success or failure. A call that already committed shall + not be reversed, because no compensating action exists for it: a committed + runner switch stays committed even when a later field update fails, and the + dialog shall present that state truthfully instead of implying the whole save + was rejected. Retrying such a save is safe in the sense that it cannot + double-apply the switch, because the switch is a value assignment rather than an + accumulation (AC-TASKS-RUNNER-SWITCH-002.11, AC-TASKS-RUNNER-SWITCH-002.14). It + is not guaranteed to succeed: if a later call in the failed sequence materialized + the task, the repeated switch is refused by the mutability gate with that + condition's code even though the stored runner is already the intended one. The + dialog shall present that refusal as the gate outcome it is, and shall not + present it as the switch having failed to apply. +- **AC-TASKS-RUNNER-SWITCH-004.4b:** When the outcome carries no reason code, + being invalid, not-found, or evaluation-unavailable rather than a typed + conflict, the dialog shall present text specific to that outcome class. An + evaluation-unavailable outcome shall be presented as retriable and shall keep + the user's selection; because AC-TASKS-RUNNER-SWITCH-002.18 covers a failed write + or commit as well as a failed read, a switch that could not be applied arrives in + this class rather than in a fourth one, and no further outcome class exists for + the dialog to handle. The dialog shall never render an empty reason or a raw + code. +- **AC-TASKS-RUNNER-SWITCH-004.4d:** A save that also starts an agent issues a + session launch, and that launch is a member of the ordered sequence + AC-TASKS-RUNNER-SWITCH-004.4c governs, not something outside it. It shall be the + **last** call in the sequence, because it is the only one that materializes the + task and therefore the only one that can make an earlier call in the same + sequence irreversible in effect. When the runner switch is rejected, the dialog + shall issue no launch, for the same reason it issues no field update: a launch + would materialize the task under the runner the user was just refused permission + to change. When the switch succeeds, the launch shall carry the switched-to + profile per AC-TASKS-RUNNER-SWITCH-003.1. +- **AC-TASKS-RUNNER-SWITCH-004.5:** When the user saves without having changed the + executor profile selection, the dialog shall issue no runner switch. **Changed + means changed by the user in this editing session.** A value the dialog placed in + the selector itself, whether by resolving a default, by restoring a remembered + preference, or by any other seeding it performs on open, is not a user change, + and the dialog shall not issue a switch on account of it. Value inequality against + the stored runner is therefore not the test: for a task that stores no executor + profile at all, a seeded selection necessarily differs from the stored value, and + treating that difference as a change would make an untouched save silently store + an explicit runner. AC-TASKS-RUNNER-SWITCH-002.16 makes that consequential, since + storing where nothing was stored is a real change that pins the task against any + later change of the resolved default. +- **AC-TASKS-RUNNER-SWITCH-004.5a:** When a task stores no executor profile, the + dialog shall present the runner the task would resolve to if launched now, and + shall present it as a resolved default rather than as a stored choice, so a user + can see what will happen without that display constituting a decision. The task + shall remain editable in this state per AC-TASKS-RUNNER-SWITCH-001.11: the user + may choose a runner explicitly, and choosing one that differs from the seeded + value shall issue a switch. +- **AC-TASKS-RUNNER-SWITCH-004.5b:** When the user's final selection is the value + the dialog seeded, the dialog shall issue no runner switch, whether the user + never touched the control or changed it and changed it back. The comparison is + against the seeded value, not against the history of interactions, so the rule + has one observable form and does not depend on tracking intermediate states. The + consequence is deliberate: a user cannot use this surface to pin the resolved + default as an explicit stored choice, because the dialog cannot distinguish that + intent from a change reverted. Silently converting a resolved default into a + durable pin is the worse of the two failures, and it is the one + AC-TASKS-RUNNER-SWITCH-002.16 would make permanent, so this criterion chooses + against it. Explicit default-pinning is named in `## Out of scope`. +- **AC-TASKS-RUNNER-SWITCH-004.6:** Desktop and mobile shall use the same gate, + the same reason text, and the same interaction. This capability introduces no + new layout and no new mobile interaction pattern. +- **AC-TASKS-RUNNER-SWITCH-004.7:** Every user-visible string this capability + introduces, including each reason code's presentation, shall be available in + all shipped locales. diff --git a/docs/specs/tasks/requirements/runner-switch-before-materialization.md b/docs/specs/tasks/requirements/runner-switch-before-materialization.md new file mode 100644 index 0000000000..18300377f7 --- /dev/null +++ b/docs/specs/tasks/requirements/runner-switch-before-materialization.md @@ -0,0 +1,211 @@ +--- +status: draft +system: tasks +created: 2026-09-07 +owners: + - kandev +--- + +# Runner Switch Before Materialization Requirements + +## Overview + +A task's runner is chosen once, when its first session is prepared, and frozen +on that session from then on. Because the workspace default executor is normally +the worktree executor, every task starts there. Moving a task today means editing +stored task metadata by hand, deleting the session, discarding the task +environment, and forcing a new session: two of those steps destroy durable state, +and none is reachable from the product. + +This capability lets a user change the runner of a task that has not yet +materialized anything, without destroying state, because nothing physical exists +to destroy. It answers two independent questions and keeps them separate: + +- **Is this task's runner still mutable?** A property of the task alone. +- **Can a particular target runner actually run this task?** A property of the + target paired with the task's repository. + +Conflating them is what makes the rule drift, so the first is projected on the +task and the second is enforced when a target is named. + +## Terminology + +- **Runner:** the executor profile a task will use, with the executor that + profile belongs to. +- **Materialized:** the task owns at least one durable runtime artifact: a + session, a task environment, a running-executor record, an attached workspace + folder, or a shared workspace group membership. +- **Mutability gate:** the ordered conditions in AC-TASKS-RUNNER-SWITCH-001.3 + deciding whether the runner can change at all. Depends only on the task. +- **Compatibility gate:** the condition in AC-TASKS-RUNNER-SWITCH-002.7 deciding + whether a named target runner can materialize this task's repository. Depends + on the target. +- **Runner switch:** one application of the runner action to one task. + +## Requirements + +### REQ-TASKS-RUNNER-SWITCH-001: Runner mutability projection + +**Intent:** A client learns whether a task's runner can be changed from one +server-derived verdict, so the rule lives in one place and cannot drift into a +second implementation in the client. + +**User story:** As a user looking at a task I have not started, I want to see +whether I can still change its runner, so that I do not attempt a change the +system will refuse. + +#### Acceptance criteria + +- **AC-TASKS-RUNNER-SWITCH-001.1:** When the system projects a task to any + client, the projection shall carry a boolean `runner_editable` and a string + `runner_ineligible_reason`. Both fields shall always be present, including + when `runner_editable` is `true` and when `runner_ineligible_reason` carries + the eligible value. Neither field is omitted on any projection path. + `runner_ineligible_reason` shall always hold a member of the closed vocabulary; + the empty string is never a permitted value. +- **AC-TASKS-RUNNER-SWITCH-001.1a:** When a projection path emits a task without + having run the mutability evaluation at all, it shall emit `runner_editable` as + `false` and `runner_ineligible_reason` as `evaluation_unavailable`. This covers + a path that never attempted an evaluation, where + AC-TASKS-RUNNER-SWITCH-001.8 covers one that ran and failed. Such a path shall + never emit `true` and never emit an empty reason. This is a backstop, not a + licence: each of the four paths named in AC-TASKS-RUNNER-SWITCH-001.5 shall + perform the evaluation, so this criterion governs only paths outside that + set. +- **AC-TASKS-RUNNER-SWITCH-001.2:** When no condition in + AC-TASKS-RUNNER-SWITCH-001.3 holds for a task, the system shall project + `runner_editable` as `true` and `runner_ineligible_reason` as `eligible`. +- **AC-TASKS-RUNNER-SWITCH-001.3:** When at least one of the following + conditions holds for a task, the system shall project `runner_editable` as + `false` and shall project the reason code of the **first** condition that + holds, evaluated in exactly this order. The order is part of the contract, so + a task that trips several conditions always reports the same code: + + 1. `task_archived`: the task is archived. + 2. `no_repository`: the task has no repository attachment. + 3. `multiple_repositories`: the task has more than one repository attachment. + 4. `session_exists`: the task has at least one session, in any state, + including terminal states. + 5. `environment_exists`: the task has at least one task environment, in any + status, including a status that indicates creation in progress or failure. + 6. `executor_running`: the task has at least one running-executor record, in + any status. + 7. `workspace_folder_attached`: the task has at least one attached workspace + folder. + 8. `workspace_path_set`: the task declares a non-empty host workspace path. + 9. `workspace_group_member`: the task holds a workspace group membership that + has not been released. + 10. `workspace_binding_not_independent`: the task's workspace binding is not + independent. A task with no parent is independent unless it declares a + shared-group workspace mode. A task with a parent is independent only when + it declares the new-workspace mode explicitly; an absent, inherited, or + shared-group mode is not independent. +- **AC-TASKS-RUNNER-SWITCH-001.4:** When the same task is evaluated repeatedly, + every evaluation completes, and no persisted state changed between them, the + system shall project the same `runner_editable` and `runner_ineligible_reason` + values every time. Determinism is a property of the evaluation, not of the + infrastructure under it: an evaluation that could not complete reports + `evaluation_unavailable` per AC-TASKS-RUNNER-SWITCH-001.8, and a later + evaluation of unchanged state that does complete reports the real verdict + instead. That difference is not a violation of this criterion, because one of + the two evaluations did not happen. +- **AC-TASKS-RUNNER-SWITCH-001.5:** When one task is read individually, within a + list, within a board snapshot, and within a task event payload, and the + evaluation completes on each, the system shall project the same + `runner_editable` and `runner_ineligible_reason` values on all four paths for + the same persisted state. As in AC-TASKS-RUNNER-SWITCH-001.4, a path whose + evaluation did not complete reports `evaluation_unavailable` under + AC-TASKS-RUNNER-SWITCH-001.8a and is not counted as a disagreement; the + criterion constrains the rule the four paths apply, not the availability of the + reads beneath any one of them. +- **AC-TASKS-RUNNER-SWITCH-001.6:** When a task is queued behind a work-in- + progress limit and has not been admitted, the system shall evaluate its + runner mutability exactly as it would for an admitted task; queuing alone + shall not make the runner immutable. +- **AC-TASKS-RUNNER-SWITCH-001.7:** When a task has a parent, the system shall + not project `runner_editable` as `false` for that reason alone; only condition + 10 of AC-TASKS-RUNNER-SWITCH-001.3 governs the parent relationship. +- **AC-TASKS-RUNNER-SWITCH-001.8:** When the system cannot complete the + evaluation because a required read fails, it shall project `runner_editable` + as `false` and `runner_ineligible_reason` as `evaluation_unavailable` rather + than omitting the fields or projecting `true`. +- **AC-TASKS-RUNNER-SWITCH-001.8a:** The fail-closed substitution of + AC-TASKS-RUNNER-SWITCH-001.8 shall apply per task, to exactly those tasks whose + own evaluation could not be completed, however many tasks the failing read + covered. When a projection evaluates several tasks together and one read fails, + a task whose verdict does not depend on the failed read shall carry its real + verdict, and only the tasks whose verdict does depend on it shall carry + `evaluation_unavailable`. A batched read that covers every task in the + projection and fails therefore degrades every task in it, and a read that + covers some degrades only those; in both cases the rule is the same one. This + does not conflict with AC-TASKS-RUNNER-SWITCH-001.5, which constrains paths + reading the same persisted state: a transient read failure is not persisted + state, so a task may carry `evaluation_unavailable` on one path and its real + verdict on another at the same moment. +- **AC-TASKS-RUNNER-SWITCH-001.9:** When a client merges a task payload that + does not carry `runner_editable`, the client shall treat the runner as not + editable and shall not retain a previously cached `true`. Unlike the derived + executor identity fields, which are gap-filled from cache, this verdict fails + closed: a stale `true` offers an action the server will refuse, while a stale + `false` only hides one a refresh restores. +- **AC-TASKS-RUNNER-SWITCH-001.9a:** When a payload's `runner_editable` is + `false` but its `runner_ineligible_reason` is absent, empty, or outside the + closed vocabulary, the client shall treat the reason as + `evaluation_unavailable`. It shall not display an unrecognized code and shall + not fall back to a cached reason. + +- **AC-TASKS-RUNNER-SWITCH-001.10:** When a task is read while a runner switch + for that task is in flight, the projection shall reflect either the state + before the switch or the state after it, and the projected executor profile + and the projected verdict in one payload shall describe the same state. +- **AC-TASKS-RUNNER-SWITCH-001.11:** When a task has never stored an explicit + executor profile and would resolve its runner from the workspace default, the + system shall evaluate its runner mutability normally and shall be able to + project it as editable. +- **AC-TASKS-RUNNER-SWITCH-001.12:** The evaluation shall not read the task's + workflow state, workflow step, or priority. A task in any state, including a + terminal one, that has materialized nothing shall be projected as editable. + +REQ-TASKS-RUNNER-SWITCH-002 (the runner switch action) lives in +[Runner switch before materialization action](runner-switch-before-materialization-action.md). +REQ-TASKS-RUNNER-SWITCH-003 (effect of a switch on the next launch) and +REQ-TASKS-RUNNER-SWITCH-004 (the runner editing surface) live in +[Runner switch before materialization effects](runner-switch-before-materialization-effects.md). +They were split out to keep each file within the requirement file-size limit; +the contract is unchanged and every acceptance criterion keeps its identifier. + +## Out of scope + +- Family tasks, excluded by condition 10 of AC-TASKS-RUNNER-SWITCH-001.3. + Moving one requires deciding what happens to its siblings, a different + contract. +- Multi-repository tasks, excluded by condition 3. A multi-repository move must + decide per-repository materialization, which this capability does not model. +- Any change to the session-ensure behavior that prepares a session when a task + with no session is opened. The typed conflict in + AC-TASKS-RUNNER-SWITCH-002.13 is deliberately the backstop for that race, + rather than a new gate on session preparation. +- Pre-filtering the runner picker by the compatibility gate. An incompatible + target is rejected on save with `target_cannot_materialize_repository` rather + than hidden. Hiding it needs a per-target verdict for every offered profile, a + second projection contract; this capability defines only the per-task one. +- Narrowing the generic task-metadata replacement surfaces. They can still + overwrite the stored executor profile wholesale, because they replace the whole + metadata document. Constraining them would change the contract for every + metadata key and belongs to whichever change takes that on. +- Changing the workspace default executor, or how a task's initial runner is + chosen at creation; owned by + [Task Create Executor Default](task-create-executor-default.md). +- Moving a runner after materialization, and any migration of an existing + session, environment, or worktree between runners. +- Pinning the resolved default as an explicit stored choice from the edit dialog. + A task that stores no executor profile is shown the runner it would resolve to, + and selecting that same value is treated as no change + (AC-TASKS-RUNNER-SWITCH-004.5b), because the dialog cannot tell a deliberate pin + from a change reverted. Offering it would need a control that expresses "pin this + default" as distinct from "select this profile", which is a surface decision this + capability does not make. Anything taking it on needs to know that the switch + action itself already supports the write — AC-TASKS-RUNNER-SWITCH-002.16 makes + storing the default an ordinary change — so the missing piece is the control and + its copy, not the contract underneath it. +- Bulk runner switching across several tasks. diff --git a/docs/specs/tasks/system-design/runner-switch-before-materialization.md b/docs/specs/tasks/system-design/runner-switch-before-materialization.md new file mode 100644 index 0000000000..dc13c1e05f --- /dev/null +++ b/docs/specs/tasks/system-design/runner-switch-before-materialization.md @@ -0,0 +1,430 @@ +--- +status: draft +system: tasks +requirements: + - REQ-TASKS-RUNNER-SWITCH-001 + - REQ-TASKS-RUNNER-SWITCH-002 + - REQ-TASKS-RUNNER-SWITCH-003 + - REQ-TASKS-RUNNER-SWITCH-004 +--- + +# Runner Switch Before Materialization System Design + +## Purpose and boundaries + +The task system owns task metadata, the point at which a launch resolves an +executor, and the durable rows that record what a task has materialized. All of +the state this capability reads and the single value it writes are task-owned, +so the task system owns this contract. + +Two adjacent contracts are consumed, not owned: + +- The executor system owns executor profiles, executors, and the per-executor + capability that says whether a runtime needs a git clone URL. The compatibility + gate calls that capability; it does not enumerate executor types. +- The workspace system owns repositories, worktrees, and workspace groups. The + mutability gate reads task-scoped rows only and does not reach into workspace + ownership rules. +- The office system owns shared workspace groups and their membership rows. This + is a boundary the capability genuinely crosses rather than merely consumes: the + membership insert is one of the writers AC-TASKS-RUNNER-SWITCH-002.3b requires + to take the task-scoped lock, so satisfying the concurrency contract means + changing office-owned code, not only task-owned code. Naming it here is what + keeps that from being discovered during implementation. + +## Prior art + +**Wiki leg (our own prior reasoning).** Receipt: searched for a `wiki-query` +skill in `~/.claude/skills/`, `.claude/skills/`, and the repository's +`.agents/skills/`; searched `~/.claude` and `~/.claude-kandev` for any path +matching `*wiki*`; checked for `~/.obsidian-wiki/config`; checked `PATH` for +`wiki-query`, `wiki-switch`, and `qmd`. None of these exist in this environment, +so no `OBSIDIAN_VAULT_PATH` could be resolved and no QMD collection was queried. +This leg is unavailable, not empty. In its place the repository's own durable +record was searched: `docs/specs/tasks/` and `docs/specs/executors/` indexes, +and every requirement naming an executor. That search produced one directly +adjacent position, described below. + +**In-repo prior reasoning.** +[Task Create Executor Default](../requirements/task-create-executor-default.md) +already settled how a task's *initial* runner is chosen, and settled it in a +direction this design deliberately extends rather than contradicts: a +repository-backed task must start in an isolated workspace unless an explicit +contrary choice is made, and a portable last-used preference must never silently +switch the executor. That decision is why every card starts on the worktree +runner, which is the condition this capability exists to relieve. It is also why +this design does not change any default: the existing requirement's position is +that defaults are safety, and an explicit per-task move is the correct escape +hatch rather than a looser default. +[Kanban task cache preserves executor fields across merges](../requirements/kanban-task-executor-cache-staleness.md) +is the second position consulted. It establishes that derived, omit-when-empty +executor fields get blanked out by client cache merges, and that the fix is to +gap-fill from cache. AC-TASKS-RUNNER-SWITCH-001.9 deliberately departs from that +position for the mutability verdict: gap-fill is right for an identity label and +wrong for a permission-shaped flag, so this field is always emitted and fails +closed on absence instead. + +**External products leg.** Receipt: the `saas-kb` MCP server and its +`search_fsm_docs` tool are not present in this session's tool set, and no MCP +configuration in this environment registers them. This leg is unavailable, and +no claim about what other products shipped is made here. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-TASKS-RUNNER-SWITCH-001` | [Components and responsibilities](#components-and-responsibilities), [Data and contracts](#data-and-contracts) | +| `REQ-TASKS-RUNNER-SWITCH-002` | [Control flow](#control-flow), [Persistence](#persistence), [Failure and recovery](#failure-and-recovery) | +| `REQ-TASKS-RUNNER-SWITCH-003` | [Control flow](#control-flow) | +| `REQ-TASKS-RUNNER-SWITCH-004` | [Client surface](#client-surface) | + +Every anchor above resolves to a heading in this document. + +## Components and responsibilities + +- **Mutability evaluator (task service).** Single implementation of the ordered + condition list. Every caller, the projection and the action alike, uses it, so + a projected verdict and an enforced verdict cannot disagree. +- **Runner action handler (task service).** Authorizes the caller, opens the + serialized transaction, re-runs the evaluator, applies the compatibility gate, + writes the single metadata key, and publishes the update event. +- **Task projection (task DTO).** Emits the verdict alongside the executor + profile identifier it already derives from task metadata. +- **Task dialog (web).** Renders the existing executor profile selector under + the projected verdict and calls the action on save. + +## Data and contracts + +**Projected fields.** `runner_editable` (boolean) and +`runner_ineligible_reason` (string) are added to the task projection. Neither is +omit-when-empty; both are always serialized. The reason vocabulary is closed: +`eligible`, `evaluation_unavailable`, and the ten condition codes named in +AC-TASKS-RUNNER-SWITCH-001.3. + +**Action.** `task.runner`, payload `{ id, executor_profile_id }`. The two-segment +name is load-bearing: the gateway's top-level task-action rule grants the +task-authorization backstop to the task namespace plus exactly one segment and +promotes `id` to the task identifier. A deeper name silently loses both. + +**Conflict vocabulary.** The action returns the ten mutability codes plus +`target_cannot_materialize_repository`. Invalid-request and not-found outcomes +are distinct from all of them, so a client can tell "you may not" from "that is +not a thing". A fourth outcome class, `evaluation_unavailable`, is distinct from +all of the above and is the only retriable one: it says the system could not +decide, not that the answer is no. The full outcome precedence, which stage wins +when several apply, is fixed by AC-TASKS-RUNNER-SWITCH-002.17. + +**State read by the evaluator.** Task archive state, the count of the task's +repository attachments, the existence of any session, task environment, running- +executor record, workspace folder attachment, or unreleased workspace group +membership, the task's declared host workspace path, and the task's declared +workspace mode paired with whether it has a parent. + +Two of those reads are non-obvious and are the reason the rule is a service +concern rather than a column lookup. A task environment is keyed to the task, +not to a session, so removing every session leaves the environment behind; and a +running-executor record has no foreign key to the session either, so session +deletion does not remove it. Both must be queried directly. + +## Control flow + +1. A client reads a task. The projection calls the evaluator and emits the + verdict with the rest of the task. +2. The user changes the executor profile in the task dialog and saves. The dialog + issues `task.runner` only when the **user** changed the selection during this + editing session — not merely when the selection differs from the stored value + (AC-TASKS-RUNNER-SWITCH-004.5). The two tests are not equivalent, and the case + that separates them is the common one: the dialog seeds an empty selector on + open, so a task that stores no executor profile always shows a selection that + differs from the stored value. Issuing on inequality would make an untouched + save store an explicit runner derived from a remembered preference, which + AC-TASKS-RUNNER-SWITCH-002.16 then makes durable — the silent carry-over the + prior art cited above exists to prevent. The dialog therefore tracks whether + the user touched the control, and a seeded value is displayed + (AC-TASKS-RUNNER-SWITCH-004.5a) without being treated as a choice. +3. The handler authorizes the caller against the task. WebSocket callers are + additionally covered by the gateway backstop; that backstop is not relied on + for any other caller. +4. **Before opening any transaction**, the compatibility gate runs. It resolves + the target profile's executor, rejecting as invalid when the profile is + missing or its executor is soft-deleted or not active; asks the executor + capability whether that runtime requires a clone URL; and if it does, resolves + whether the task's single repository can produce one. Only the *ordering* of + candidates is taken from the launch path: a recorded remote URL, then a + provider-derived URL, then the repository's local checkout origin. The launch + path's own helper cannot be reused as-is, because it collapses "no origin is + configured" and "the origin lookup failed" into the same empty answer and puts + no timeout on the subprocess it shells out to. This gate needs those two cases + to produce different outcomes and needs the call bounded, so the resolution + used here preserves the error and timeout signal separately from empty rather + than inheriting that helper's return shape. Absent, + empty and whitespace-only candidates fall through to the next; an exhausted + list yields `target_cannot_materialize_repository`, while a lookup that errors + or exceeds its timeout yields `evaluation_unavailable`. Because this runs + before the transaction that validates how many repositories the task has, the + step records *which* repository it resolved against, and skips entirely when + the task does not have exactly one at that moment — a skip produces no verdict + and no outcome, leaving `no_repository` or `multiple_repositories` to be + reported by the mutability gate (AC-TASKS-RUNNER-SWITCH-002.7c). This step + **resolves** the verdict but does not report it: the result is carried into the + transaction and applied at its ordered position in step 7, after the mutability + re-check, so a task that fails both reports the mutability code + (AC-TASKS-RUNNER-SWITCH-002.17). Resolution runs outside the lock precisely + because its last step inspects a local checkout with a subprocess rather than + reading a column: holding the task row lock across that call would stall every + session-creation path for the task for as long as the subprocess ran. The same + property is why the gate is in the action and not in the per-task projection. +5. The handler opens a transaction that takes **the raw task row lock, not the + task cleanup barrier**. Two primitives exist and they are not + interchangeable: the barrier takes the row lock *and* refuses the caller when + a resource-cleanup job for that task is prepared, pending, running, or waiting + to retry, while the raw lock takes the row lock only. The switch takes the raw + lock. Using the barrier would introduce a cleanup-in-progress rejection that + is outside the closed conflict vocabulary and has no stage in the outcome + precedence, so a builder would have to invent an outcome for it. Ordering + against cleanup is what the contract asks for, and the raw lock supplies it; + being *rejected* by cleanup is not required and is not wanted. The gate + already answers correctly in both cleanup phases without it: while the rows + still exist the session, environment, or running-executor condition refuses + the switch, and once cleanup has removed them the task genuinely holds nothing + and a switch is legitimate. Taking that lock serializes the transaction + against every writer that can make a gate condition start holding + (AC-TASKS-RUNNER-SWITCH-002.3a) — every session-creation path, including the + Office one that takes the row lock independently rather than through the + barrier, plus task environment creation, running-executor record creation, + repository attachment, workspace folder attachment, group membership, and the + workspace path and mode writes. The locked section performs database reads and + one write, and nothing else. +6. Inside the transaction the evaluator runs again. A failing condition aborts + with the typed conflict for the first failing condition in the fixed order. A + read that fails aborts with `evaluation_unavailable`. +7. The compatibility verdict resolved in step 4 is applied — but first the + handler confirms, still inside the transaction, that the task's single + attached repository is the one step 4 resolved against. That is a column read, + not a subprocess, so it does not reintroduce the cost step 4 exists to avoid. + If the repository changed in between, the resolved verdict describes something + the task no longer has, so it is discarded and the request rejects as the + retriable `evaluation_unavailable` rather than being applied + (AC-TASKS-RUNNER-SWITCH-002.7c). If it rejects on its own terms, the handler + aborts with that code. Otherwise it compares the target against the + stored value: equal means a no-op that commits nothing and reports success, + having passed both gates; different means it writes only the executor profile + key and commits. +8. On a committed change the handler publishes the task-updated event carrying + the refreshed projection and the task's updated-at value from the committing + transaction. Clients discard an event older than the value they hold, so an + out-of-order delivery cannot resurrect a superseded runner + (AC-TASKS-RUNNER-SWITCH-002.19). +9. The next session preparation for the task reads the stored executor profile + and binds it, and the executor it belongs to, onto the created session. No + other code path changes. + +## Failure and recovery + +- Every rejection happens before or inside the transaction and commits nothing, + so a rejected switch is indistinguishable from one never attempted. +- The evaluator fails closed. A read error yields `evaluation_unavailable` in + the projection and aborts the action with the `evaluation_unavailable` outcome; + it never yields an eligible verdict and never reports a mutability conflict, + which would tell the user their task is ineligible when the system merely could + not tell. That outcome is retriable and is the only one that is. +- A projection path that never runs the evaluation at all, rather than running it + and failing, emits the same fail-closed pair. The verdict fields are never + emitted as language defaults, because the empty string is outside the closed + vocabulary and a `false` with no reason is indistinguishable from a real + refusal (AC-TASKS-RUNNER-SWITCH-001.1a). +- The action is a value assignment, so a client that cannot tell whether an + attempt committed may simply repeat it. No idempotency token is defined, + because none is needed and an unused one would be another thing to keep + correct. +- The known race is a user opening a task, which prepares a session for a task + that has none, while a switch is in flight. The row lock forces a total order: + either the switch commits and the session takes the new runner, or the session + commits and the switch is refused with `session_exists`. The dialog surfaces + that refusal; the session-preparation path is not modified. +- The sibling races all have that same shape and that same resolution, and the + rule is stated once for all of them rather than per row + (AC-TASKS-RUNNER-SWITCH-002.3a): every writer that can make a gate condition + start holding takes the same task-scoped lock. Task environment creation and + running-executor record creation are the least obvious members, because a task + environment survives the deletion of every session and a running-executor record + has no foreign key to a session either, so each can appear on a task the session + checks would call clean — but repository attachment, workspace folder + attachment, group membership, and the workspace path and mode writes are in the + same set for the same reason. Serializing only some of them is what would leave + the rest observed but not enforceable. The rule also reaches a writer that + changes no row count at all — an in-place update of an existing task-repository + link — because it can change which repository the compatibility gate resolved + against, which is why AC-TASKS-RUNNER-SWITCH-002.3a states that second clause + alongside the first. +- Publishing the task-updated event is outside the transaction and best-effort. A + failed publication does not roll back or retry the committed switch and is not + reported as a failed switch, because the switch did commit + (AC-TASKS-RUNNER-SWITCH-003.3a). The recovery is not a replayed event: a repeat + of the request is a no-op that publishes nothing, so what closes the gap is that + any later read of the task carries the committed runner and the recomputed + verdict. That is why no outbox or delivery receipt is specified — the read path + is already the convergence path. + +### The writer audit, performed + +AC-TASKS-RUNNER-SWITCH-002.3b requires this set to be enumerated rather than left +to be discovered, so it is recorded here. Two classes, and the split is what makes +the obligation a bounded piece of work rather than an open-ended one. + +**Class 1 — already serialized, no change required.** The switch's transaction +locks the task's own row, so any writer that updates that same row is ordered +against it for free. That covers archiving the task, writing the task's host +workspace path, and writing the task's workspace mode: all three live in the task +row, and the metadata writes reach them with a targeted update of that row rather +than a separate table. Session creation and task environment creation are also in +this class, but for the other reason — they take the task-scoped lock explicitly, +one through the cleanup barrier and one directly. Both barrier and raw lock take +the same row, so a writer using either is ordered against a switch using the other. + +**Class 2 — requires the lock to be added.** These write a different table and take +no task-scoped lock today: + +| Writer | Gate condition | Why it is unserialized today | +| --- | --- | --- | +| Running-executor record write | 6 `executor_running` | Executes directly on the shared handle, in no transaction. | +| Workspace source attachment | 2, 3 `no_repository` / `multiple_repositories`, 7 `workspace_folder_attached` | Opens a transaction, but its only task-row lock is inside a parent-relation guard that returns immediately when no expected parent is supplied — so a task with no parent, the shape this capability most commonly serves, is not locked at all. | +| Workspace group membership insert | 9 `workspace_group_member` | Executes directly on the shared handle, in no transaction. Office-owned. | +| In-place task-repository link update | none — changes the resolved repository | Executes directly on the shared handle. The attachment count does not move, so the first clause of AC-TASKS-RUNNER-SWITCH-002.3a does not reach it; the second clause does. | + +Four writers across two subsystems. That is the whole of the retrofit, and it is +stated here so the work can be sized before it starts rather than found during it. +The in-transaction repository confirmation of AC-TASKS-RUNNER-SWITCH-002.7c stays +even once the fourth writer takes the lock: it compares the link's last-updated +column as well as its identity, and exists so that a writer introduced outside this +audit is still caught rather than silently trusted. + +## Client surface + +The task dialog already renders the executor profile selector when editing. It is +never written to the task — but it is **not** unused, and the difference matters. +The edit save already forwards the selected profile to the session launch it +issues when an agent profile is chosen, so today the picker silently governs the +first session while leaving the task's stored runner untouched. Two consequences +follow, and both are contract, not implementation detail. First, the edit +interaction can materialize the task, which is why the launch is the last member +of the ordered save sequence and is suppressed by a rejected switch +(AC-TASKS-RUNNER-SWITCH-004.4d). Second, a launch that names a profile explicitly +must name the one the switch just committed, or the observable effect of a switch +would depend on which of the two paths ran (AC-TASKS-RUNNER-SWITCH-003.1). + +This capability makes the picker effective on the task itself and puts it under +the server's verdict rather than the current task-state approximation, which gates +on workflow state and matches none of the ten conditions. + +The selector's baseline is part of the contract for the same reason. The dialog +seeds an empty selector on open, so for a task that stores no executor profile the +displayed value is always something the dialog chose, never something the task +holds. It is presented as a resolved default rather than as a stored choice, and +displaying it is not selecting it: the switch is issued when the final selection +differs from the value the dialog seeded, not when it differs from the value the +task stores (AC-TASKS-RUNNER-SWITCH-004.5, AC-TASKS-RUNNER-SWITCH-004.5a, +AC-TASKS-RUNNER-SWITCH-004.5b). Comparing against the seed rather than against the +interaction history is what keeps the rule to one observable form, and it is why +pinning a resolved default is a named exclusion rather than an unstated gap. + +The dialog reads `runner_editable` and `runner_ineligible_reason` from the task +it was opened with and never recomputes either. When the verdict is `false` the +selector is not offered for editing and the reason is shown; an absent or +unrecognized reason is presented as `evaluation_unavailable` rather than as raw +text. + +Save ordering is load-bearing because the dialog's save is already several +non-transactional calls and this adds one more. The runner switch goes first, the +field updates run only if it succeeds, and the session launch, when there is one, +goes last. A rejection leaves every other edit unsaved and still in the open +dialog. That ordering is what keeps a refused switch from leaving half a save +behind, and it is why the rejection path reports that nothing was saved rather +than only that the runner did not move. The launch is last because it is the only +call that materializes the task: once it commits, the switch ahead of it can no +longer be retried on equal terms, since the task now has a session and the gate +would refuse it. + +Ordering first is not the same as being atomic, so the rule covers the rest of +the sequence too (AC-TASKS-RUNNER-SWITCH-004.4c). A failure at any call stops the +sequence there, and the calls that already committed stay committed — there is no +compensating action for a runner switch, and inventing one would mean a second +switch that the gate could refuse. So the dialog reports which part of the save +applied instead of collapsing the whole thing to one success or one failure, and +a retry is safe because repeating the switch is a no-op. + +Desktop and mobile share this gate, this copy and this interaction; the +capability introduces no new layout and no new mobile interaction pattern. + +## Persistence + +The write is a single-key update to the task's metadata document. It must not be +a read-modify-write of the whole map: the generic protected metadata update +replaces the document wholesale and preserves only the deferred launch record, +so routing this change through it would delete the task's agent profile and +leave the card unable to start. The repository already provides single-key +metadata writes that set one JSON path in the database rather than rewriting the +document, and a conditional variant of the same primitive that refuses the write +when the task has an active session. This capability uses that family of writes. + +Note for implementation, of the same kind as the running-executor note below: +every member of that family currently executes on the shared database handle and +none of them accepts a transaction. The switch's write must happen *inside* the +serialized transaction, so a variant that takes the transaction handle is +required; the repository already has this shape elsewhere, where a metadata +removal is parameterized over its executor rather than bound to the shared +handle. Writing through the existing handle-bound form instead would put the +write outside the lock, where it would land even when the transaction rolls back +and would silently defeat the guarantees that a rejected switch persists nothing +and that a switch never commits alongside a session prepared under the old +runner. + +No schema change is required. `runner_editable` and `runner_ineligible_reason` +are derived per read and are never stored. + +Because the verdict is derived, a list projection must not fan out into a +per-task query for each of the eight row-existence checks. +AC-TASKS-RUNNER-SWITCH-001.5 is the observable constraint: the same task must +carry the same verdict whether it was read alone or in a list, which is what +makes a batched implementation verifiable rather than merely faster. + +The evaluator's reads are several, and the executor profile it is projected +beside is a ninth. Left as independent point-in-time queries they can straddle a +concurrent switch and produce a payload whose profile and verdict describe +different states, which AC-TASKS-RUNNER-SWITCH-001.10 forbids. So the reads +backing one projected task — the stored executor profile and every row-existence +check behind its verdict — resolve against a single consistent snapshot, whether +that is one read transaction, one snapshot read, or one joined query. This is +about coherence within a payload, not authority: the projection stays advisory +and the action stays the thing that decides, per AC-TASKS-RUNNER-SWITCH-002.3. +An advisory verdict may be stale, and the client handles that +(AC-TASKS-RUNNER-SWITCH-004.4); it may not be internally inconsistent. + +A read that fails degrades only the tasks whose own verdict depended on it +(AC-TASKS-RUNNER-SWITCH-001.8a), so a batched query covering a whole board +degrades the whole board and one covering a subset degrades that subset, under +one rule rather than two. + +## Security + +Authorization is per task and is the same authorization that governs the other +top-level task actions. The service authorizes every caller on every transport, +including WebSocket. The gateway backstop covers WebSocket dispatch and is an +extra layer there, not a substitute for the service guard and not a reason to +skip it — the service cannot reliably tell which transport a request arrived on, +and the other top-level task mutations in this system already authorize +unconditionally for exactly that reason. The action accepts exactly two fields and writes +exactly one derived value, so it is not a path for a caller to inject arbitrary +task metadata. + +## Observability + +A successful switch logs the task, the previous executor profile, and the new +one. A rejection logs the task and the reason code. The reason code is the same +closed vocabulary the client receives, so a support question about a refused +switch can be answered from logs without reproducing the state. + +## Related decisions + +- [ADR 0028: task-create last-used source of truth](../../../decisions/0028-task-create-last-used-source-of-truth.md) +- [ADR 0041: backend-owned portable user settings](../../../decisions/0041-backend-owned-portable-user-settings.md)