fix(web): keep repository tasks on worktree defaults (#2136)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user