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 b80e1be9ac..7d013e2bf0 100644 --- a/apps/web/components/task-create-dialog-effects-executor.test.ts +++ b/apps/web/components/task-create-dialog-effects-executor.test.ts @@ -84,6 +84,24 @@ function worktreeExecutor(): StoreSelections["executors"][number] { } describe("useDefaultSelectionsEffect - executor profile defaults", () => { + it("does not let a saved local profile override the worktree default", async () => { + const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "" }); + const local = localExecutor(); + const worktree = worktreeExecutor(); + const sel = makeSel({ + executors: [local, worktree], + lastUsedExecutorProfileId: PROFILE_LOCAL, + userSettingsLoaded: true, + }); + + renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); + + await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE)); + expect(fs.setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_LOCAL); + await waitFor(() => expect(fs.setExecutorId).toHaveBeenCalledWith(worktree.id)); + expect(fs.setExecutorId).not.toHaveBeenCalledWith(local.id); + }); + it("defaults repo-backed tasks to the worktree profile when no profile was saved", async () => { const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "" }); const local = localExecutor(); @@ -100,7 +118,11 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { const fs = makeDefaultSelFs({ executorId: "", executorProfileId: "", noRepository: true }); const worktree = worktreeExecutor(); const local = localExecutor(); - const sel = makeSel({ executors: [worktree, local] }); + const sel = makeSel({ + executors: [worktree, local], + lastUsedExecutorProfileId: PROFILE_WORKTREE, + userSettingsLoaded: true, + }); renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); @@ -116,7 +138,11 @@ describe("useDefaultSelectionsEffect - executor profile defaults", () => { }); const worktree = worktreeExecutor(); const local = localExecutor(); - const sel = makeSel({ executors: [worktree, local] }); + const sel = makeSel({ + executors: [worktree, local], + lastUsedExecutorProfileId: PROFILE_WORKTREE, + userSettingsLoaded: true, + }); renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); @@ -206,7 +232,7 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B)); }); - it("does not pick a fallback executor id while a valid last-used profile is restoring", async () => { + it("keeps an explicit local workspace default ahead of a saved worktree profile", async () => { const local = localExecutor(); const worktree = worktreeExecutor(); const sel = makeSel({ @@ -232,18 +258,19 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( }, ); - await waitFor(() => expect(setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B)); + await waitFor(() => expect(setExecutorProfileId).toHaveBeenCalledWith(PROFILE_LOCAL)); + expect(setExecutorProfileId).not.toHaveBeenCalledWith(PROFILE_WORKTREE_B); rerender({ formState: makeDefaultSelFs({ - executorProfileId: PROFILE_WORKTREE_B, + executorProfileId: PROFILE_LOCAL, executorId: "", setExecutorId, setExecutorProfileId, }), }); - await waitFor(() => expect(setExecutorId).toHaveBeenCalledWith(worktree.id)); - expect(setExecutorId).not.toHaveBeenCalledWith(local.id); + await waitFor(() => expect(setExecutorId).toHaveBeenCalledWith(local.id)); + expect(setExecutorId).not.toHaveBeenCalledWith(worktree.id); }); }); diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 62d2fa54db..1eaa48c52f 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -239,19 +239,17 @@ function pickDefaultExecutorProfileId( : allProfiles; if (eligibleProfiles.length === 0) return null; - if ( - lastUsedExecutorProfileId && - eligibleProfiles.some((p) => p.id === lastUsedExecutorProfileId) - ) { - return lastUsedExecutorProfileId; - } - const executorId = pickDefaultExecutorId( executors, workspaceDefaults, noRepository, preferLocalExecutor, ); + const lastUsedProfile = eligibleProfiles.find((p) => p.id === lastUsedExecutorProfileId); + if (lastUsedProfile && lastUsedProfile._executorId === executorId) { + return lastUsedProfile.id; + } + const executorProfile = eligibleProfiles.find((p) => p._executorId === executorId); return executorProfile?.id ?? eligibleProfiles[0].id; } @@ -270,6 +268,7 @@ type ExecutorProfileLastUsedState = { eligibleProfiles: ExecutorProfileCandidate[]; settingsId: string | null; settingsValid: boolean; + settingsCompatible: boolean; }; function getExecutorProfileLastUsedState( @@ -282,11 +281,21 @@ function getExecutorProfileLastUsedState( noRepository || preferLocalExecutor ? allProfiles.filter((p) => !isWorktreeExecutorType(p._executorType)) : allProfiles; + const defaultExecutorId = pickDefaultExecutorId( + executors, + context.workspaceDefaults, + noRepository, + preferLocalExecutor, + ); + const settingsProfile = eligibleProfiles.find((p) => p.id === settingsId); return { allProfiles, eligibleProfiles, settingsId, - settingsValid: Boolean(settingsId && eligibleProfiles.some((p) => p.id === settingsId)), + settingsValid: Boolean(settingsProfile), + settingsCompatible: Boolean( + settingsProfile && settingsProfile._executorId === defaultExecutorId, + ), }; } @@ -302,7 +311,7 @@ export function shouldWaitForLastUsedExecutorProfile(context: ExecutorAutopickCo context, context.lastUsedExecutorProfileId ?? null, ); - if (!lastUsedProfile.settingsValid) return false; + if (!lastUsedProfile.settingsCompatible) return false; if (isDebug()) { selectionDebug("executor-autopick", { current: "-", @@ -311,6 +320,7 @@ export function shouldWaitForLastUsedExecutorProfile(context: ExecutorAutopickCo workspace_default: context.workspaceDefaults?.default_executor_id ?? "-", no_repository: context.noRepository, prefer_local_executor: context.preferLocalExecutor, + settings_compatible: lastUsedProfile.settingsCompatible, source: "executor-profile-last-used", }); } @@ -329,6 +339,7 @@ function logExecutorProfileAutopick( pick: pick ?? "-", settings_id: lastUsed.settingsId ?? "-", settings_valid: lastUsed.settingsValid, + settings_compatible: lastUsed.settingsCompatible, profile_count: lastUsed.allProfiles.length, workspace_default_executor: context.workspaceDefaults?.default_executor_id ?? "-", no_repository: context.noRepository, diff --git a/apps/web/components/task-create-dialog-executor-wait.test.ts b/apps/web/components/task-create-dialog-executor-wait.test.ts index 5012227a09..44ad1bb351 100644 --- a/apps/web/components/task-create-dialog-executor-wait.test.ts +++ b/apps/web/components/task-create-dialog-executor-wait.test.ts @@ -4,6 +4,8 @@ import type { StoreSelections } from "@/components/task-create-dialog-types"; const WORKTREE_EXECUTOR_ID = "exec-worktree"; const WORKTREE_PROFILE_ID = "profile-b"; +const LOCAL_EXECUTOR_ID = "exec-local"; +const LOCAL_PROFILE_ID = "profile-local"; function makeWorktreeExecutor(): StoreSelections["executors"][number] { return { @@ -13,6 +15,14 @@ function makeWorktreeExecutor(): StoreSelections["executors"][number] { } as unknown as StoreSelections["executors"][number]; } +function makeLocalExecutor(): StoreSelections["executors"][number] { + return { + id: LOCAL_EXECUTOR_ID, + type: "local", + profiles: [{ id: LOCAL_PROFILE_ID, executor_id: LOCAL_EXECUTOR_ID, name: "Local" }], + } as unknown as StoreSelections["executors"][number]; +} + describe("shouldWaitForLastUsedExecutorProfile", () => { it("waits only while a valid last-used executor profile can restore", () => { const worktreeExecutor = makeWorktreeExecutor(); @@ -36,4 +46,16 @@ describe("shouldWaitForLastUsedExecutorProfile", () => { }), ).toBe(false); }); + + it("does not wait for a valid profile from another resolved executor", () => { + expect( + shouldWaitForLastUsedExecutorProfile({ + executors: [makeLocalExecutor(), makeWorktreeExecutor()], + workspaceDefaults: null, + lastUsedExecutorProfileId: LOCAL_PROFILE_ID, + noRepository: false, + preferLocalExecutor: false, + }), + ).toBe(false); + }); }); diff --git a/apps/web/components/task-create-dialog-handlers.test.ts b/apps/web/components/task-create-dialog-handlers.test.ts index f59d872eb6..229832ffe6 100644 --- a/apps/web/components/task-create-dialog-handlers.test.ts +++ b/apps/web/components/task-create-dialog-handlers.test.ts @@ -127,6 +127,47 @@ describe("local repository creation selection", () => { }); }); +describe("repository source changes", () => { + function renderRepositoryChangeHandler( + rows: Array<{ key: string; repositoryId?: string; localPath?: string; branch: string }>, + ) { + const fs = { + repositories: rows, + executorProfileId: WORKTREE_PROFILE_ID, + updateRepository: vi.fn(), + setExecutorId: vi.fn(), + setExecutorProfileId: vi.fn(), + setFreshBranchEnabled: vi.fn(), + setCurrentLocalBranch: vi.fn(), + setCurrentLocalBranchLoading: vi.fn(), + } as unknown as Parameters[0]; + const { result } = renderHook(() => useDialogHandlers(fs, [repository("repo-1")])); + return { fs, result }; + } + + it("clears the executor when a workspace repository becomes an unmanaged local path", () => { + const { fs, result } = renderRepositoryChangeHandler([ + { key: "row-0", repositoryId: "repo-1", branch: "main" }, + ]); + + result.current.handleRowRepositoryChange("row-0", "/work/discovered"); + + expect(fs.setExecutorId).toHaveBeenCalledWith(""); + expect(fs.setExecutorProfileId).toHaveBeenCalledWith(""); + }); + + it("clears the executor when an unmanaged local path becomes a workspace repository", () => { + const { fs, result } = renderRepositoryChangeHandler([ + { key: "row-0", localPath: "/work/discovered", branch: "main" }, + ]); + + result.current.handleRowRepositoryChange("row-0", "repo-1"); + + expect(fs.setExecutorId).toHaveBeenCalledWith(""); + expect(fs.setExecutorProfileId).toHaveBeenCalledWith(""); + }); +}); + describe("task title handling", () => { it("clamps astral Unicode input before updating the dialog state", () => { const setTaskName = vi.fn(); diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index 9ab32a0339..9416943ddc 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -226,10 +226,16 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) const handleRowRepositoryChange = useCallback( (key: string, value: string) => { const isWorkspaceRepo = repositories.some((r: Repository) => r.id === value); + const wasLocalPath = Boolean(fs.repositories.find((row) => row.key === key)?.localPath); + const isLocalPath = !isWorkspaceRepo && Boolean(value); const patch: Partial = isWorkspaceRepo ? { repositoryId: value, localPath: undefined, branch: "" } : { repositoryId: undefined, localPath: value, branch: "" }; fs.updateRepository(key, patch); + if (wasLocalPath !== isLocalPath) { + fs.setExecutorId(""); + fs.setExecutorProfileId(""); + } if (isWorkspaceRepo) { syncTaskCreateLastUsed({ repository_id: value, branch: null }); } else { diff --git a/apps/web/e2e/tests/task/create-task-executor-default.spec.ts b/apps/web/e2e/tests/task/create-task-executor-default.spec.ts new file mode 100644 index 0000000000..53d97441e0 --- /dev/null +++ b/apps/web/e2e/tests/task/create-task-executor-default.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { KanbanPage } from "../../pages/kanban-page"; + +// Exercises the regular task-create dialog (New Task in the sidebar), so run +// with the office feature disabled. +useRegularMode(); + +type ExecutorProfile = { id: string; name: string }; + +type ExecutorSummary = { + id: string; + type: string; + profiles?: ExecutorProfile[]; +}; + +async function executorProfiles(apiClient: { + listExecutors: () => Promise<{ executors: ExecutorSummary[] }>; +}) { + const { executors } = await apiClient.listExecutors(); + const localExecutor = executors.find((executor) => ["local", "local_pc"].includes(executor.type)); + const worktreeExecutor = executors.find((executor) => executor.type === "worktree"); + const localProfile = localExecutor?.profiles?.[0]; + const worktreeProfile = worktreeExecutor?.profiles?.[0]; + expect(localProfile, "a direct local executor profile is required by the fixture").toBeDefined(); + expect(worktreeProfile, "a worktree executor profile is required by the fixture").toBeDefined(); + return { localExecutor, localProfile: localProfile!, worktreeProfile: worktreeProfile! }; +} + +async function saveTaskCreatePreference( + apiClient: { + saveUserSettings: (settings: { + workspace_id: string; + workflow_filter_id: string; + task_create_last_used: { + repository_id: string; + branch: string; + agent_profile_id: string; + executor_profile_id: string; + }; + }) => Promise; + }, + seedData: { + workspaceId: string; + workflowId: string; + repositoryId: string; + agentProfileId: string; + }, + executorProfileId: string, +) { + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: seedData.workflowId, + task_create_last_used: { + repository_id: seedData.repositoryId, + branch: "main", + agent_profile_id: seedData.agentProfileId, + executor_profile_id: executorProfileId, + }, + }); +} + +async function openCreateTask(testPage: import("@playwright/test").Page) { + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); +} + +test.describe("Task-create executor safety defaults", () => { + test("executor safety default ignores a saved local profile", async ({ + testPage, + apiClient, + seedData, + }) => { + const { localProfile, worktreeProfile } = await executorProfiles(apiClient); + await apiClient.updateWorkspace(seedData.workspaceId, { default_executor_id: "" }); + await saveTaskCreatePreference(apiClient, seedData, localProfile.id); + + try { + await openCreateTask(testPage); + await expect(testPage.getByTestId("executor-profile-selector")).toContainText( + worktreeProfile.name, + ); + } finally { + await apiClient.updateWorkspace(seedData.workspaceId, { default_executor_id: "" }); + await saveTaskCreatePreference(apiClient, seedData, worktreeProfile.id); + } + }); + + test("executor safety default honors an explicit local workspace default", async ({ + testPage, + apiClient, + seedData, + }) => { + const { localExecutor, localProfile, worktreeProfile } = await executorProfiles(apiClient); + expect(localExecutor).toBeDefined(); + await apiClient.updateWorkspace(seedData.workspaceId, { + default_executor_id: localExecutor!.id, + }); + await saveTaskCreatePreference(apiClient, seedData, worktreeProfile.id); + + try { + await openCreateTask(testPage); + await expect(testPage.getByTestId("executor-profile-selector")).toContainText( + localProfile.name, + ); + } finally { + await apiClient.updateWorkspace(seedData.workspaceId, { default_executor_id: "" }); + await saveTaskCreatePreference(apiClient, seedData, worktreeProfile.id); + } + }); +}); diff --git a/docs/decisions/2026-08-01-repository-task-executor-defaults.md b/docs/decisions/2026-08-01-repository-task-executor-defaults.md new file mode 100644 index 0000000000..94fddda07d --- /dev/null +++ b/docs/decisions/2026-08-01-repository-task-executor-defaults.md @@ -0,0 +1,44 @@ +# ADR-2026-08-01-repository-task-executor-defaults: Resolve Task Executor Policy Before Last-Used Profile + +**Status:** accepted +**Date:** 2026-08-01 +**Area:** frontend + +## Context + +ADR 0028 made `task_create_last_used.executor_profile_id` portable and backend-owned. The task +dialog consumed that profile before resolving the task source and workspace executor default, so a +single Local task could make later ordinary repository-backed tasks default to Local across +browsers and upgrades. That portable convenience preference conflicts with Worktree's role as the +isolation-first fallback. + +## Decision + +Task creation separates executor policy from profile convenience. The dialog first resolves an +eligible executor from the task source and explicit workspace default: unmanaged local paths and +repository-less tasks prefer direct Local, a valid workspace default wins for ordinary repository +tasks, and otherwise Worktree is preferred. The backend-owned last-used profile is restored only +when it belongs to that resolved executor; it never switches executors. + +Manual profile selection still applies to the task being created and successful creation still +records the selected profile under `users.settings.task_create_last_used`. No persistence schema or +API contract changes. + +## Consequences + +- Ordinary repository tasks return to an isolation-first Worktree default after a one-off Local + task, including in another browser or after an update. +- Explicit Local workspace defaults and explicit local-path/repository-less sources keep their + current behavior. +- A single global last-used profile cannot remember a preferred profile for every executor. After + crossing executor types, the dialog may use the resolved executor's first profile. +- The policy can be implemented and tested entirely in the frontend selection logic. + +## Alternatives Considered + +1. **Always force Worktree for every repository task.** Rejected because it would ignore an + explicit workspace default and remove an intentional configuration surface. +2. **Remove last-used executor persistence.** Rejected because profile restoration remains useful + when the saved profile belongs to the executor selected by policy. +3. **Persist one last-used profile per executor.** Rejected for this repair because it expands the + user-settings contract and requires migration and UI semantics beyond the reported regression. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index ac9fa7eca5..be806d5b0b 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -94,5 +94,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-30-runtime-task-state-before-running-event | [Publish Task State Before Running Session State](2026-07-30-runtime-task-state-before-running-event.md) | accepted | backend, frontend, protocol, workflow | 2026-07-30 | | 2026-07-31-isolate-manual-pr-review-content | [Isolate Manual PR Review Content](2026-07-31-isolate-manual-pr-review-content.md) | accepted | infra, workflow, security | 2026-07-31 | | 2026-07-31-system-service-user-continuity | [Preserve System Service Identity Across Reinstallation](2026-07-31-system-service-user-continuity.md) | accepted | backend, cli, security, operations | 2026-07-31 | +| 2026-08-01-repository-task-executor-defaults | [Resolve Task Executor Policy Before Last-Used Profile](2026-08-01-repository-task-executor-defaults.md) | accepted | frontend | 2026-08-01 | | 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 | | 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 | diff --git a/docs/plans/task-create-executor-default/plan.md b/docs/plans/task-create-executor-default/plan.md new file mode 100644 index 0000000000..3a4c744ee4 --- /dev/null +++ b/docs/plans/task-create-executor-default/plan.md @@ -0,0 +1,125 @@ +--- +spec: docs/specs/tasks/task-create-executor-default.md +created: 2026-08-01 +status: complete +--- + +# Implementation Plan: Task Create Executor Default + +## Overview + +Change the task-create selection order so source/workspace policy resolves the executor before a +portable last-used profile is considered. Lock the behavior down first with focused Vitest +regressions, then prove the backend-settings/cross-browser path through the existing Create Task +Playwright flow. No backend, schema, API, or rendered-layout change is required. + +## Confirmed root cause + +`pickDefaultExecutorProfileId` accepts any eligible `lastUsedExecutorProfileId` before calling +`pickDefaultExecutorId`. For an ordinary repository-backed task, a valid saved Local profile is +therefore returned before the no-workspace-default Worktree fallback is resolved. The executor-ID +effect also waits for any valid saved profile and then derives the executor from that profile, +making the Local selection portable across browsers and updates by design. + +## Frontend + +### Executor policy and profile restoration + +- Update `apps/web/components/task-create-dialog-effects.ts` so + `pickDefaultExecutorProfileId` first obtains the executor selected by `pickDefaultExecutorId`. +- Restore `lastUsedExecutorProfileId` only when its flattened profile candidate belongs to that + resolved executor. +- Prefer the resolved executor's first profile when the saved profile belongs to another executor. + Preserve the current first-eligible fallback if the resolved executor has no usable profile. +- Update `shouldWaitForLastUsedExecutorProfile` so executor-ID auto-pick waits only for a saved + profile compatible with the resolved executor. A saved Local profile must not suppress the + Worktree executor-ID fallback for an ordinary repository-backed task. +- Preserve the current task-source rules in `pickDefaultExecutorId`: explicit unmanaged local + paths and repository-less tasks prefer direct Local; a valid workspace default wins; otherwise + `DEFAULT_LOCAL_EXECUTOR_TYPE` (`worktree`) is preferred. +- Keep task-create persistence and submit handlers unchanged. A manual selection still applies to + the current task and is still recorded by the backend. + +### Mobile design contract + +- **Desktop and mobile outcome:** the existing Executor Profile selector shows the same + policy-resolved profile for the same task source and workspace settings. +- **Nearest shipped mobile exemplar:** the existing mobile Create Task dialog remains the + composition and interaction baseline. +- **Presentation and hierarchy:** no markup, overlay, scrolling, safe-area, focus, or touch target + changes; only the shared selection state changes. +- **Shared logic:** desktop and mobile already call the same task-create effects, so the fix stays + in that shared hook. +- **Mobile proof:** focused unit coverage is sufficient because this is state normalization inside + the unchanged dialog. Existing mobile task-create E2E remains the layout/touch coverage; no new + mobile-only interaction is introduced. + +## Tests + +- **What:** a saved Local profile cannot override the Worktree fallback for an ordinary + repository-backed task with no workspace default. + - **File:** `apps/web/components/task-create-dialog-effects-executor.test.ts` + - **How:** render `useDefaultSelectionsEffect` with Local and Worktree executors, loaded backend + settings, and `lastUsedExecutorProfileId` set to Local; assert Worktree profile and executor ID. +- **What:** an explicit Local workspace default remains authoritative. + - **File:** `apps/web/components/task-create-dialog-effects-executor.test.ts` + - **How:** use the same executor set with `default_executor_id` set to Local and a saved Worktree + profile; assert Local profile and executor ID. +- **What:** source constraints remain authoritative for an explicit local path and repository-less + task. + - **File:** `apps/web/components/task-create-dialog-effects-executor.test.ts` + - **How:** extend the existing local-path/no-repository cases with a saved Worktree profile and + assert a direct Local profile. +- **What:** a saved alternative Worktree profile is still restored when Worktree is the resolved + executor, and missing-profile fallback behavior remains unchanged. + - **File:** `apps/web/components/task-create-dialog-effects-executor.test.ts` + - **How:** retain the existing restoration case and add/adjust table cases around resolved + executor membership. + +## E2E Tests + +- **Scenario:** **GIVEN** backend user settings contain a last-used Local profile and the workspace + has no executor default, **WHEN** Create Task opens for the seeded repository in a browser + context, **THEN** the Executor Profile selector shows Worktree. + - **File:** `apps/web/e2e/tests/task/create-task-executor-default.spec.ts` + - **What to verify:** seed the Local profile through `saveUserSettings`, navigate through the + regular Kanban UI, open the dialog, and assert the visible Worktree selection. +- **Scenario:** **GIVEN** the workspace explicitly defaults to Local, **WHEN** Create Task opens for + the seeded repository, **THEN** the selector shows Local even when the saved profile is Worktree. + - **File:** `apps/web/e2e/tests/task/create-task-executor-default.spec.ts` + - **What to verify:** set and later restore `default_executor_id`, assert the visible Local + profile, and restore the backend last-used profile to Worktree so the worker-scoped fixture + does not leak state. + +## Implementation Tasks + +- [x] [Task 01: Enforce executor-first profile selection](task-01-executor-first-selection.md) +- [x] [Task 02: Prove portable settings behavior in Create Task](task-02-create-task-e2e.md) + +Execution is sequential in the primary conversation. No subagent delegation is planned or +authorized. + +## Validation + +Run from the repository root unless noted otherwise: + +- `cd apps/web && pnpm test -- --run components/task-create-dialog-effects-executor.test.ts components/task-create-dialog-executor-wait.test.ts` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm e2e:run --project chromium tests/task/create-task-executor-default.spec.ts` + +Completed validation: 14 focused Vitest tests passed, the web typecheck passed, targeted ESLint and +Prettier checks passed, and the managed Chromium run passed both Create Task scenarios. + +The managed E2E runner performs the required production build before Playwright. This worktree ran +`cd apps && pnpm install --frozen-lockfile` before the recorded validation; fresh worktrees must run +that prerequisite if frontend dependencies are missing. + +## Risks + +- The executor-ID and executor-profile effects run independently and settle through microtasks. + Tests must assert the final paired selection, not only one setter call, so a transient mismatch + cannot become a regression. +- Existing E2E fixtures preserve task-create last-used fields on partial settings writes. New tests + must restore a Worktree profile, and tests that set a workspace default must clear it in cleanup. +- The single stored last-used profile cannot retain a preferred profile for multiple executor + types; adding per-executor history remains explicitly out of scope. diff --git a/docs/plans/task-create-executor-default/task-01-executor-first-selection.md b/docs/plans/task-create-executor-default/task-01-executor-first-selection.md new file mode 100644 index 0000000000..f41934951f --- /dev/null +++ b/docs/plans/task-create-executor-default/task-01-executor-first-selection.md @@ -0,0 +1,70 @@ +--- +id: "01-executor-first-selection" +title: "Enforce executor-first profile selection" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/task-create-executor-default.md" +--- + +# Task 01: Enforce executor-first profile selection + +## Intent + +Prevent a portable last-used profile from changing the executor selected by task-source and +workspace policy while preserving intentional workspace and local-source defaults. + +## Acceptance + +- An ordinary repository-backed task with no workspace default selects Worktree even when the + backend last-used profile is Local. +- Explicit Local workspace defaults, explicit unmanaged local paths, and repository-less tasks + still select Local. +- A saved profile is restored only within the resolved executor, with existing eligible fallback + behavior preserved when that executor has no profile. + +## TDD sequence + +1. Add the Local-last-used/Worktree-default regression to + `task-create-dialog-effects-executor.test.ts` and run it to confirm RED for the expected Local + selection. +2. Add the workspace-default and source-constraint cases. +3. Change the smallest selection helpers/effects required in + `task-create-dialog-effects.ts` and run the focused suite to GREEN. +4. Refactor helper names/log metadata only where necessary to describe executor compatibility. + +## Files likely touched + +- `apps/web/components/task-create-dialog-effects.ts` +- `apps/web/components/task-create-dialog-effects-executor.test.ts` + +## Dependencies + +None. + +## Parallelism + +`sequential` — this task owns the shared selection behavior required by the E2E task. + +## Inputs + +- Spec sections `What`, `Persistence guarantees`, and `Scenarios`. +- Plan sections `Confirmed root cause` and `Executor policy and profile restoration`. +- ADR 0028, ADR 0041, and ADR-2026-08-01-repository-task-executor-defaults. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- components/task-create-dialog-effects-executor.test.ts` +- `cd apps/web && pnpm run typecheck` + +## Risks + +- Independent effects can produce order-sensitive setter calls; assert the settled executor/profile + pair through rerenders where needed. +- Do not remove or rewrite backend last-used persistence as part of this task. + +## Output contract + +Report the RED failure, final files changed, focused test/typecheck results, remaining risks, and +update this task plus `plan.md` status in the same conversation. diff --git a/docs/plans/task-create-executor-default/task-02-create-task-e2e.md b/docs/plans/task-create-executor-default/task-02-create-task-e2e.md new file mode 100644 index 0000000000..b0b2720b3a --- /dev/null +++ b/docs/plans/task-create-executor-default/task-02-create-task-e2e.md @@ -0,0 +1,74 @@ +--- +id: "02-create-task-e2e" +title: "Prove portable settings behavior in Create Task" +status: done +wave: 2 +depends_on: ["01-executor-first-selection"] +plan: "plan.md" +spec: "../../specs/tasks/task-create-executor-default.md" +--- + +# Task 02: Prove portable settings behavior in Create Task + +## Intent + +Exercise the real backend-settings-to-boot-state-to-dialog path so the isolation-first default is +proven across the same boundary used by another browser or an upgraded frontend. + +## Acceptance + +- With a backend-saved Local profile and no workspace executor default, the visible Create Task + selector shows Worktree for the seeded repository. +- With an explicit Local workspace default, the visible selector shows Local even when the saved + profile is Worktree. +- The test restores both workspace and task-create preference state so later worker-scoped tests do + not inherit its executor choice. + +## TDD sequence + +1. Add the backend-saved Local-profile UI regression and run the focused Playwright grep to confirm + RED against the current selection behavior if Task 01 is temporarily reverted or before its + production change. +2. Add the explicit workspace-default scenario and deterministic cleanup. +3. Run both scenarios against the production build through the managed E2E runner. + +## Files likely touched + +- `apps/web/e2e/tests/task/create-task-executor-default.spec.ts` + +## Dependencies + +- Task 01 must be complete. + +## Parallelism + +`sequential` — it validates Task 01 through shared task-create fixtures and production assets. + +## Inputs + +- Spec scenarios for portable Local last-used state and explicit workspace defaults. +- Existing `ApiClient.saveUserSettings`, `ApiClient.updateWorkspace`, `ApiClient.listExecutors`, + `KanbanPage`, and `executor-profile-selector` patterns. +- The fixture warning that partial task-create settings writes preserve omitted fields. + +## Verification + +- `cd apps/web && pnpm e2e:run --project chromium tests/task/create-task-executor-default.spec.ts` + +## Mobile parity + +No mobile-only E2E is added because the change modifies shared selection state inside the existing +responsive dialog and does not alter composition, navigation, scrolling, touch targets, or pointer +behavior. Existing mobile Create Task specs continue to cover reachability and layout. + +## Risks + +- Cleanup must restore `default_executor_id` and a non-empty Worktree + `task_create_last_used.executor_profile_id`; omitting either can leak through the worker-scoped + backend. +- Assertions should target the selected profile text, not option ordering. + +## Output contract + +The focused managed Chromium run passed both scenarios (`2 passed`); each test restores the workspace +default and backend task-create preference in `finally` cleanup, and no blockers remain. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index f24be77b94..68b04b952a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -73,6 +73,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [parent-child-task-stop](tasks/parent-child-task-stop.md) | shipped | | [mcp-task-agent-profile-default](tasks/mcp-task-agent-profile-default/spec.md) | shipped | | [runtime-state-publication-order](tasks/runtime-state-publication-order.md) | shipped | +| [task-create-executor-default](tasks/task-create-executor-default.md) | approved | | [explicit-completion-signal](workflow/explicit-completion-signal/spec.md) | shipped | ## agents/ — agent governance diff --git a/docs/specs/tasks/task-create-executor-default.md b/docs/specs/tasks/task-create-executor-default.md new file mode 100644 index 0000000000..c72044283c --- /dev/null +++ b/docs/specs/tasks/task-create-executor-default.md @@ -0,0 +1,76 @@ +--- +status: approved +created: 2026-08-01 +owner: Kandev +decision: ADR-2026-08-01-repository-task-executor-defaults +--- + +# Task Create Executor Default + +## Why + +Repository-backed tasks should start in isolated workspaces unless the user or workspace has made +an explicit contrary choice. A portable last-used Local executor profile must not silently turn a +later ordinary task into an in-place run, including after a Kandev update or in another browser. + +## Broken behavior + +The task-create dialog currently restores a valid backend-owned last-used executor profile before +it resolves the task source and workspace default. After any Local task, that portable preference +can therefore select Local for the next ordinary repository-backed task even when the workspace +has no Local default. + +## What + +- The task-create dialog resolves the default executor before it considers the last-used executor + profile. +- An ordinary repository-backed task uses the workspace's valid configured default executor; when + the workspace has no valid default, it uses Worktree when Worktree is available. +- A task with no repository, or a task created from an explicit unmanaged local path, continues to + prefer a direct Local executor. +- The backend-owned last-used executor profile may refine the profile choice only when that profile + belongs to the already-resolved default executor. It cannot switch the executor. +- A valid explicit workspace default, including Local, remains authoritative. +- A user may manually select a different executor profile for the task being created. Successful + task creation continues to record that profile in backend user settings, but the recorded value + does not override the next task's executor policy. +- If the preferred executor or its profiles are unavailable, the dialog uses the existing eligible + fallback so task creation remains usable. +- Desktop and mobile use the same selection policy. This change does not alter dialog composition, + touch behavior, or responsive layout. + +## Persistence guarantees + +`users.settings.task_create_last_used.executor_profile_id` remains a portable backend-owned user +preference as defined by [ADR 0028](../../decisions/0028-task-create-last-used-source-of-truth.md) +and [ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md). No browser storage, +schema migration, or new persistence field is introduced. The stored profile is a convenience +within the executor selected by policy, not a portable override of that policy. + +## Scenarios + +- **GIVEN** an ordinary repository-backed task, no workspace executor default, and a last-used + Local profile, **WHEN** the user opens Create Task, **THEN** a Worktree profile is selected. +- **GIVEN** the same backend user settings are opened in a different browser or after an update, + **WHEN** the user opens an ordinary repository-backed task dialog, **THEN** the same Worktree + safety default is selected. +- **GIVEN** an ordinary repository-backed task and an explicit Local workspace default, **WHEN** + the user opens Create Task, **THEN** a Local profile is selected even if the last-used profile is + Worktree. +- **GIVEN** an explicit unmanaged local path or no repository, **WHEN** the user opens Create Task, + **THEN** a direct Local profile is preferred. +- **GIVEN** Worktree is the resolved executor and the last-used profile is another valid Worktree + profile, **WHEN** the dialog restores selections, **THEN** that Worktree profile is selected. +- **GIVEN** Local was selected manually for one ordinary task and the workspace still has no + executor default, **WHEN** the next ordinary repository-backed task dialog opens, **THEN** it + returns to Worktree. +- **GIVEN** the preferred executor has no usable profile, **WHEN** the dialog resolves defaults, + **THEN** it selects the first existing eligible fallback rather than leaving an invalid profile. + +## Out of scope + +- Removing backend persistence for task-create selections. +- Adding per-executor last-used profile history. +- Preventing users from manually choosing Local for an ordinary task. +- Changing workspace executor settings or their explicit precedence. +- Changing task-create dialog layout or mobile interaction patterns.