fix(web): keep repository tasks on worktree defaults (#2136)

This commit is contained in:
Carlos Florêncio
2026-08-01 20:34:23 +01:00
committed by GitHub
parent 1e353fbbbc
commit b29ee5e6d2
13 changed files with 627 additions and 16 deletions
@@ -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);
});
});
@@ -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,
@@ -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);
});
});
@@ -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<typeof useDialogHandlers>[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();
@@ -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<TaskRepoRow> = 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 {
@@ -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<void>;
},
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);
}
});
});
@@ -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.
+1
View File
@@ -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 |
@@ -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.
@@ -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.
@@ -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.
+1
View File
@@ -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
@@ -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.