6cd50ab841
Backend: - service_task_environments: fail closed when checkAnySessionRunning errors so a transient DB hiccup can't bypass the running-session guard and tear down a live container/sandbox; teardown is now truly best-effort with errors.Join across container/sandbox/worktree, so a failed Docker call doesn't permanently orphan the worktree on retry. Adds tests for the running-check error path and the multi-resource best-effort path. - executor_execute: propagate GetTaskEnvironmentByTaskID errors instead of silently launching a fresh environment that orphans existing containers/worktrees; gate environment reuse on matching ExecutorType so a profile switch doesn't pass stale PreviousExecutionID/container IDs to the wrong backend. - task_environment_handlers: reject malformed JSON on POST /tasks/:id/environment/reset (still allow empty body) so a destructive endpoint doesn't silently proceed with a bad payload. - worktree (PushEnvironmentBranch): bound git push with a 30s context timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0 so a stalled remote can't hang the request thread. - manager_environment: explicitly call ensureClient() before ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing Docker daemon surfaces as "docker unavailable" rather than the generic "container manager not initialized". - container: WorkingDir always uses the in-container /workspace path rather than the host workspace path (Docker would happily start in an unrelated directory in host bind-mount mode); bootstrap now logs the prepare-script exit code so failures show up in container logs. - docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint so the same config struct produces consistent behavior on both creation paths. Frontend: - executor-settings-button: extract useTaskEnvironment hook (component shrinks below the 100-line limit); only treat 404 as empty env (other errors no longer wipe a valid environment); track "loaded once" via a ref so loadEnv doesn't depend on env state — that dep was creating a new function identity on every successful poll, cancelling the 3-second interval and turning the popover into a tight loop hammering /environment/live; copy-shell command uses sh instead of bash so it works on user-built minimal images. - task-reset-env-confirm-dialog: AlertDialogAction onClick calls e.preventDefault() so Radix's auto-close doesn't dismiss the dialog before the async reset completes (loading spinner now visible; close is parent-controlled via onOpenChange). - remote-credentials-card: track env method in selectedIds the same way files/gh_cli_token are tracked, and let initialChoice match either selectedIds OR envSecretId. First-time env-credential setup for an agent that exposes both files+env was broken: clicking the env radio never updated state, choice re-derived to "none", and the secret picker disappeared before the user could pick anything. - e2e/executor-reuse: select the acknowledge checkbox by label, not by positional last(), so adding another checkbox to the dialog can't silently target the wrong control. Format pass on prettier-flagged files; gofmt fixed two whitespace nits in lifecycle files.
202 lines
6.7 KiB
TypeScript
202 lines
6.7 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import { KanbanPage } from "../../pages/kanban-page";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
|
|
|
/**
|
|
* Tests for executor environment reuse across sessions.
|
|
* Verifies that task environments persist across sessions and that
|
|
* the API response includes container_id/sandbox_id fields.
|
|
*
|
|
* Note: E2E tests run with KANDEV_DOCKER_ENABLED=false and KANDEV_MOCK_AGENT=only,
|
|
* so Docker container reuse cannot be tested end-to-end here.
|
|
* Docker reconnect logic is covered by Go unit tests.
|
|
*/
|
|
test.describe("Executor reuse", () => {
|
|
test("task environment API response includes executor_type field", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
// Create task with session, wait for completion
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Env Fields Task",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{ timeout: 30_000, message: "Waiting for session to finish" },
|
|
)
|
|
.toBe(true);
|
|
|
|
// Get environment and verify response shape
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env).not.toBeNull();
|
|
expect(env!.task_id).toBe(task.id);
|
|
expect(env!.status).toBe("ready");
|
|
// executor_type should be present (standalone for mock agent)
|
|
expect(env!.executor_type).toBeDefined();
|
|
});
|
|
|
|
test("second session reuses same task environment by default", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
|
|
// 1. Create task with first session
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Reuse Env Task",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
// 2. Wait for first session to complete
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{ timeout: 30_000, message: "Waiting for first session to finish" },
|
|
)
|
|
.toBe(true);
|
|
|
|
// 3. Capture environment
|
|
const envBefore = await apiClient.getTaskEnvironment(task.id);
|
|
expect(envBefore).not.toBeNull();
|
|
|
|
// 4. Navigate to task and create second session via dialog
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const card = kanban.taskCardByTitle("Reuse Env Task");
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// 5. Open new session dialog and submit
|
|
await session.openNewSessionDialog();
|
|
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
|
|
await session.newSessionPromptInput().fill("/e2e:simple-message");
|
|
await session.newSessionStartButton().click();
|
|
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
|
|
|
|
// 6. Wait for second session
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return sessions.length;
|
|
},
|
|
{ timeout: 30_000, message: "Waiting for second session to appear" },
|
|
)
|
|
.toBe(2);
|
|
|
|
// 7. Verify same environment ID (default reuse behavior)
|
|
const envAfter = await apiClient.getTaskEnvironment(task.id);
|
|
expect(envAfter).not.toBeNull();
|
|
expect(envAfter!.id).toBe(envBefore!.id);
|
|
|
|
// 8. Both sessions reference the same environment
|
|
const { sessions: allSessions } = await apiClient.listTaskSessions(task.id);
|
|
for (const s of allSessions) {
|
|
expect(s.task_environment_id).toBe(envBefore!.id);
|
|
}
|
|
});
|
|
|
|
test("reset environment from executor settings popover removes the environment", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(90_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Reset Env Task",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
},
|
|
);
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
|
},
|
|
{ timeout: 30_000, message: "Waiting for session to finish" },
|
|
)
|
|
.toBe(true);
|
|
|
|
const envBefore = await apiClient.getTaskEnvironment(task.id);
|
|
expect(envBefore).not.toBeNull();
|
|
|
|
// Navigate to task
|
|
const kanban = new KanbanPage(testPage);
|
|
await kanban.goto();
|
|
|
|
const card = kanban.taskCardByTitle("Reset Env Task");
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
await card.click();
|
|
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
|
|
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
|
|
// Open popover
|
|
await testPage.getByTestId("executor-settings-button").click();
|
|
const popover = testPage.getByTestId("executor-settings-popover");
|
|
await expect(popover).toBeVisible({ timeout: 5_000 });
|
|
|
|
// Click Reset → confirmation dialog
|
|
await testPage.getByTestId("executor-settings-reset").click();
|
|
// Pick the acknowledgement checkbox by its label rather than position so
|
|
// adding another checkbox to the dialog (e.g. push-before-reset) doesn't
|
|
// silently make this test click the wrong control.
|
|
await testPage.getByLabel("I understand any uncommitted changes will be lost.").click();
|
|
await testPage.getByTestId("reset-env-confirm").click();
|
|
|
|
// Poll environment until gone
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
return env;
|
|
},
|
|
{ timeout: 15_000, message: "Waiting for environment to be reset" },
|
|
)
|
|
.toBeNull();
|
|
});
|
|
});
|