695 lines
25 KiB
TypeScript
695 lines
25 KiB
TypeScript
import { test, expect } from "../../fixtures/docker-test-base";
|
|
import {
|
|
buildAlpineE2EImage,
|
|
E2E_ALPINE_IMAGE_TAG,
|
|
E2E_IMAGE_TAG,
|
|
} from "../../fixtures/docker-probe";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
import {
|
|
dockerCurrentBranch,
|
|
dockerFileContent,
|
|
dockerInspectExists,
|
|
dockerRemove,
|
|
dockerState,
|
|
dockerStop,
|
|
waitForDockerContainerRemoved,
|
|
} from "../../helpers/docker";
|
|
import {
|
|
waitForLatestSessionDone,
|
|
waitForSessionDone,
|
|
waitForSessionEnvironment,
|
|
} from "../../helpers/session";
|
|
|
|
test.describe("Docker executor — launch + reuse + recovery", () => {
|
|
test("executes the prepare script on Alpine BusyBox", async ({ apiClient, seedData }) => {
|
|
test.setTimeout(180_000);
|
|
buildAlpineE2EImage();
|
|
|
|
const { executors } = await apiClient.listExecutors();
|
|
const dockerExec = executors.find((e) => e.type === "local_docker");
|
|
expect(dockerExec?.id).toBeTruthy();
|
|
const profile = await apiClient.createExecutorProfile(dockerExec!.id, {
|
|
name: "E2E Docker Alpine",
|
|
config: { image_tag: E2E_ALPINE_IMAGE_TAG },
|
|
prepare_script: "printf alpine-prepare > /tmp/kandev-alpine-prepare",
|
|
cleanup_script: "",
|
|
env_vars: [],
|
|
});
|
|
|
|
try {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Alpine Bootstrap",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
executor_profile_id: profile.id,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for Alpine Docker session");
|
|
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env?.container_id).toBeTruthy();
|
|
expect(dockerFileContent(env!.container_id!, "/tmp/kandev-alpine-prepare")).toBe(
|
|
"alpine-prepare",
|
|
);
|
|
} finally {
|
|
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test("launches a session in a real container and exposes container_id", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Launch",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first Docker session");
|
|
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env).not.toBeNull();
|
|
expect(env!.executor_type).toBe("local_docker");
|
|
expect(env!.container_id, "task environment must record container_id").toBeTruthy();
|
|
expect(dockerInspectExists(env!.container_id!), "container should exist on host").toBe(true);
|
|
// Suffix is the first 6 chars of the task UUID (deterministic so resume
|
|
// lands on the same branch even when the env row predates the suffix
|
|
// change), so the pattern is hex chars rather than a 3-char random tail.
|
|
expect(dockerCurrentBranch(env!.container_id!)).toMatch(/^feature\/docker-launch-[0-9a-f]{6}$/);
|
|
});
|
|
|
|
test("shows Docker container wait progress during slow bootstrap", async ({
|
|
apiClient,
|
|
seedData,
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const { executors } = await apiClient.listExecutors();
|
|
const dockerExec = executors.find((e) => e.type === "local_docker");
|
|
expect(dockerExec?.id).toBeTruthy();
|
|
const profile = await apiClient.createExecutorProfile(dockerExec!.id, {
|
|
name: "E2E Docker Slow",
|
|
config: { image_tag: E2E_IMAGE_TAG },
|
|
prepare_script: "sleep 20",
|
|
cleanup_script: "",
|
|
env_vars: [],
|
|
});
|
|
const persistedProfile = await apiClient.getExecutorProfile(dockerExec!.id, profile.id);
|
|
expect(persistedProfile.prepare_script).toBe("sleep 20");
|
|
|
|
try {
|
|
const task = await apiClient.createTask(seedData.workspaceId, "Docker Slow Progress", {
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
});
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
|
|
const launchPromise = apiClient.launchSession(
|
|
{
|
|
task_id: task.id,
|
|
agent_profile_id: seedData.agentProfileId,
|
|
executor_profile_id: profile.id,
|
|
workflow_step_id: seedData.startStepId,
|
|
prompt: "/e2e:simple-message",
|
|
},
|
|
90_000,
|
|
);
|
|
|
|
const panel = testPage.getByTestId("prepare-progress-panel");
|
|
await expect(panel).toBeVisible({ timeout: 15_000 });
|
|
await expect(panel).toHaveAttribute("data-status", "preparing");
|
|
await expect(panel.getByTestId("prepare-progress-header-spinner")).toBeVisible();
|
|
await expect(panel).toContainText("Waiting for Docker container");
|
|
await expect(testPage.getByTestId("submit-message-button")).toBeDisabled({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
const launched = await launchPromise;
|
|
await waitForSessionDone(
|
|
apiClient,
|
|
task.id,
|
|
launched.session_id,
|
|
"Waiting for slow Docker session",
|
|
);
|
|
await expect(panel).toHaveAttribute("data-status", "completed", { timeout: 30_000 });
|
|
} finally {
|
|
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test("archives a task and removes its Docker container", async ({ apiClient, seedData }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Archive Cleanup",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for archive cleanup session");
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env?.container_id).toBeTruthy();
|
|
const containerID = env!.container_id!;
|
|
|
|
try {
|
|
await apiClient.archiveTask(task.id);
|
|
await waitForDockerContainerRemoved(containerID, "Archived task should remove container");
|
|
} finally {
|
|
dockerRemove(containerID);
|
|
}
|
|
});
|
|
|
|
test("deletes a task and removes its Docker container", async ({ apiClient, seedData }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Delete Cleanup",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for delete cleanup session");
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env?.container_id).toBeTruthy();
|
|
const containerID = env!.container_id!;
|
|
|
|
try {
|
|
await apiClient.deleteTask(task.id);
|
|
await waitForDockerContainerRemoved(containerID, "Deleted task should remove container");
|
|
} finally {
|
|
dockerRemove(containerID);
|
|
}
|
|
});
|
|
|
|
test("restarts an externally stopped container and reuses the task environment", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker External Stop Reuse",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first Docker session");
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before?.container_id).toBeTruthy();
|
|
|
|
dockerStop(before!.container_id!);
|
|
await expect
|
|
.poll(() => dockerState(before!.container_id!), {
|
|
timeout: 10_000,
|
|
message: "Waiting for external Docker stop",
|
|
})
|
|
.toBe("exited");
|
|
|
|
const launched = await apiClient.launchSession({
|
|
task_id: task.id,
|
|
agent_profile_id: seedData.agentProfileId,
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
workflow_step_id: seedData.startStepId,
|
|
prompt: "/e2e:simple-message",
|
|
});
|
|
|
|
await waitForSessionDone(
|
|
apiClient,
|
|
task.id,
|
|
launched.session_id,
|
|
"Waiting for second Docker session",
|
|
);
|
|
const after = await apiClient.getTaskEnvironment(task.id);
|
|
expect(after?.id).toBe(before!.id);
|
|
expect(after?.container_id).toBe(before!.container_id);
|
|
await expect
|
|
.poll(() => dockerState(before!.container_id!), {
|
|
timeout: 30_000,
|
|
message: "Waiting for Docker container to restart",
|
|
})
|
|
.toBe("running");
|
|
});
|
|
|
|
test("blocks chat input after an externally stopped container disconnects the executor", async ({
|
|
apiClient,
|
|
seedData,
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker External Stop Chat Gate",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first Docker session");
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before?.container_id).toBeTruthy();
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
const editor = testPage.locator(".tiptap.ProseMirror").first();
|
|
await expect(editor).toHaveAttribute("contenteditable", "true");
|
|
|
|
dockerStop(before!.container_id!);
|
|
await expect
|
|
.poll(() => dockerState(before!.container_id!), {
|
|
timeout: 10_000,
|
|
message: "Waiting for external Docker stop",
|
|
})
|
|
.toBe("exited");
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const res = await apiClient.rawRequest(
|
|
"GET",
|
|
`/api/v1/tasks/${task.id}/environment/live`,
|
|
);
|
|
const live = (await res.json()) as { container?: { state?: string } };
|
|
return live.container?.state;
|
|
},
|
|
{
|
|
timeout: 10_000,
|
|
message: "Waiting for Kandev live environment status to observe the stop",
|
|
},
|
|
)
|
|
.toBe("exited");
|
|
|
|
await expect(editor).toBeHidden({ timeout: 15_000 });
|
|
await expect(session.recoveryResumeButton()).toBeVisible();
|
|
await session.recoveryResumeButton().click();
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const res = await apiClient.rawRequest(
|
|
"GET",
|
|
`/api/v1/tasks/${task.id}/environment/live`,
|
|
);
|
|
const live = (await res.json()) as { container?: { state?: string } };
|
|
return live.container?.state;
|
|
},
|
|
{
|
|
timeout: 30_000,
|
|
message: "Waiting for explicit restart to bring the container back",
|
|
},
|
|
)
|
|
.toBe("running");
|
|
|
|
await session.clickTab("Terminal");
|
|
await session.expectTerminalConnected(30_000);
|
|
await session.typeInTerminal("printf terminal-after-restart");
|
|
await session.expectTerminalHasText("terminal-after-restart");
|
|
});
|
|
|
|
test("page refresh after an external stop resumes the same Docker container", async ({
|
|
apiClient,
|
|
seedData,
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Refresh Reuse",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first Docker session");
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before?.container_id).toBeTruthy();
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
|
|
dockerStop(before!.container_id!);
|
|
await expect
|
|
.poll(() => dockerState(before!.container_id!), {
|
|
timeout: 10_000,
|
|
message: "Waiting for external Docker stop",
|
|
})
|
|
.toBe("exited");
|
|
|
|
await testPage.reload();
|
|
await session.waitForLoad();
|
|
|
|
await expect
|
|
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.container_id, {
|
|
timeout: 30_000,
|
|
message: "Refresh resume must keep the original task container",
|
|
})
|
|
.toBe(before!.container_id);
|
|
await expect
|
|
.poll(() => dockerState(before!.container_id!), {
|
|
timeout: 60_000,
|
|
message: "Waiting for refresh resume to restart the original container",
|
|
})
|
|
.toBe("running");
|
|
|
|
await session.clickTab("Terminal");
|
|
await session.expectTerminalConnected(30_000);
|
|
await session.typeInTerminal("printf terminal-after-refresh");
|
|
await session.expectTerminalHasText("terminal-after-refresh");
|
|
});
|
|
|
|
test("reset environment from executor settings popover removes the Docker container", async ({
|
|
apiClient,
|
|
seedData,
|
|
testPage,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Popover Reset",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for reset session");
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env?.container_id).toBeTruthy();
|
|
const containerID = env!.container_id!;
|
|
|
|
await testPage.goto(`/t/${task.id}`);
|
|
const session = new SessionPage(testPage);
|
|
await session.waitForLoad();
|
|
|
|
await testPage.getByTestId("executor-settings-button").click();
|
|
const popover = testPage.getByTestId("executor-settings-popover");
|
|
await expect(popover).toBeVisible({ timeout: 5_000 });
|
|
await testPage.getByTestId("executor-settings-reset").click();
|
|
await testPage.getByLabel("I understand any uncommitted changes will be lost.").click();
|
|
await testPage.getByTestId("reset-env-confirm").click();
|
|
|
|
await waitForDockerContainerRemoved(containerID, "Reset should remove Docker container");
|
|
await expect
|
|
.poll(async () => await apiClient.getTaskEnvironment(task.id), {
|
|
timeout: 15_000,
|
|
message: "Waiting for Docker environment row to be reset",
|
|
})
|
|
.toBeNull();
|
|
});
|
|
|
|
test("multiple sessions on the same task share one task environment", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Multi-Session",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first session");
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before!.container_id).toBeTruthy();
|
|
|
|
const launched = await apiClient.launchSession({
|
|
task_id: task.id,
|
|
agent_profile_id: seedData.agentProfileId,
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
prompt: "/e2e:simple-message",
|
|
auto_start: true,
|
|
});
|
|
|
|
await waitForSessionEnvironment(apiClient, {
|
|
taskId: task.id,
|
|
sessionId: launched.session_id,
|
|
expectedEnvironmentId: before!.id,
|
|
message: "Waiting for second Docker session to reuse the task environment",
|
|
});
|
|
|
|
const after = await apiClient.getTaskEnvironment(task.id);
|
|
expect(after?.id).toBe(before!.id);
|
|
expect(after?.container_id).toBe(before!.container_id);
|
|
|
|
// The durable contract for a launched session: it is bound to the same
|
|
// task_environment_id rather than creating its own container-scoped env.
|
|
// The list can also include unlaunched CREATED sessions from workflow
|
|
// automation; those legitimately have no task_environment_id yet.
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
const launchedSession = sessions.find((s) => s.id === launched.session_id);
|
|
expect(launchedSession?.task_environment_id).toBe(before!.id);
|
|
});
|
|
|
|
// Regression: a stored prepare_script that pre-dates the worktree-branch
|
|
// checkout block must STILL land the agent on the kandev-managed feature
|
|
// branch. Until kandev moved the checkout into a non-overridable postlude,
|
|
// any profile created in the UI snapshotted the then-current default into
|
|
// its prepare_script field — and older snapshots clone main and stop. The
|
|
// empty-script e2e fixture papered over this because it falls through to
|
|
// the runtime default; this test forces a real stored script.
|
|
test("custom prepare_script without worktree checkout still lands on feature branch", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const { executors } = await apiClient.listExecutors();
|
|
const dockerExec = executors.find((e) => e.type === "local_docker");
|
|
expect(dockerExec?.id).toBeTruthy();
|
|
|
|
// Older Docker default kandev shipped: clone the chosen branch, run the
|
|
// user's repo setup, no kandev-managed feature-branch checkout. Any user
|
|
// who created a profile under that default has this stored verbatim. The
|
|
// safe.directory line is added so this test runs cleanly against the
|
|
// e2e file:// remote which has a UID-mismatched checkout; in production
|
|
// the user's actual stale script may or may not include it.
|
|
const staleScript = `#!/bin/sh
|
|
set -eu
|
|
{{git.identity_setup}}
|
|
git config --global --add safe.directory '*'
|
|
git config --global url."https://github.com/".insteadOf "git@github.com:"
|
|
git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
|
{{github.auth_setup}}
|
|
git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}
|
|
cd {{workspace.path}}
|
|
git remote set-url origin "$(git remote get-url origin | sed 's|https://[^@]*@github.com/|https://github.com/|')" 2>/dev/null || true
|
|
{{repository.setup_script}}
|
|
`;
|
|
|
|
const profile = await apiClient.createExecutorProfile(dockerExec!.id, {
|
|
name: "E2E Docker Stale Script",
|
|
config: { image_tag: E2E_IMAGE_TAG },
|
|
prepare_script: staleScript,
|
|
cleanup_script: "",
|
|
env_vars: [],
|
|
});
|
|
|
|
try {
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Stale Prep",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: profile.id,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for stale-prep session");
|
|
|
|
const env = await apiClient.getTaskEnvironment(task.id);
|
|
expect(env?.container_id).toBeTruthy();
|
|
// Even with a stored script that doesn't include the checkout block,
|
|
// kandev must end up on the feature branch — the kandev-managed
|
|
// checkout is an invariant, not something the user can drop.
|
|
expect(dockerCurrentBranch(env!.container_id!)).toMatch(
|
|
/^feature\/docker-stale-prep-[0-9a-f]{6}$/,
|
|
);
|
|
} finally {
|
|
await apiClient.deleteExecutorProfile(profile.id).catch(() => {});
|
|
}
|
|
});
|
|
|
|
test("stops the agent then resumes onto the same container without re-cloning", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(240_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker Stop Resume",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first Docker session");
|
|
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before?.container_id).toBeTruthy();
|
|
const containerID = before!.container_id!;
|
|
const branchBefore = dockerCurrentBranch(containerID);
|
|
expect(branchBefore).toMatch(/^feature\/docker-stop-resume-[0-9a-f]{6}$/);
|
|
|
|
const { sessions: sessionsBefore } = await apiClient.listTaskSessions(task.id);
|
|
const firstSession = sessionsBefore[0];
|
|
expect(firstSession?.id).toBeTruthy();
|
|
|
|
// Stop via the same WS path the UI uses.
|
|
await apiClient.stopSession({
|
|
session_id: firstSession!.id,
|
|
reason: "e2e stop",
|
|
force: false,
|
|
});
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const { sessions } = await apiClient.listTaskSessions(task.id);
|
|
return sessions.find((s) => s.id === firstSession!.id)?.state;
|
|
},
|
|
{ timeout: 30_000, message: "Waiting for first session to reach CANCELLED" },
|
|
)
|
|
.toBe("CANCELLED");
|
|
|
|
// Resume by launching a new session on the same task — the UI Resume
|
|
// button takes this path (it just creates a new session bound to the
|
|
// same task environment).
|
|
const launched = await apiClient.launchSession({
|
|
task_id: task.id,
|
|
agent_profile_id: seedData.agentProfileId,
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
workflow_step_id: seedData.startStepId,
|
|
prompt: "/e2e:simple-message",
|
|
});
|
|
await waitForSessionDone(
|
|
apiClient,
|
|
task.id,
|
|
launched.session_id,
|
|
"Waiting for resumed Docker session",
|
|
);
|
|
|
|
const after = await apiClient.getTaskEnvironment(task.id);
|
|
expect(after?.id, "task environment is reused on resume").toBe(before!.id);
|
|
expect(after?.container_id, "container is reused on resume").toBe(containerID);
|
|
|
|
// Container is still alive and on the same kandev feature branch — no
|
|
// re-clone, no re-checkout to base.
|
|
expect(dockerInspectExists(containerID)).toBe(true);
|
|
expect(dockerCurrentBranch(containerID), "feature branch is preserved").toBe(branchBefore);
|
|
});
|
|
|
|
// FIXME: blocked on backend gap — DockerExecutor.reconnectToContainer constructs
|
|
// its agentctl ControlClient with no auth token (the original handshake token
|
|
// is held only in memory and lost across launches). Reconnect 401s, the
|
|
// executor falls back to creating a fresh container on each launch, and the
|
|
// task environment row never updates its container_id. Once the executor
|
|
// persists/looks up the auth token (e.g. via the SecretStore secret already
|
|
// stamped on the previous execution's metadata), this test can verify the
|
|
// recovery path picks a brand-new container_id.
|
|
test.fixme("recovers from an externally removed container by launching a fresh one", async ({
|
|
apiClient,
|
|
seedData,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
"Docker External Stop",
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: [seedData.repositoryId],
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
},
|
|
);
|
|
await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for first session");
|
|
const before = await apiClient.getTaskEnvironment(task.id);
|
|
expect(before!.container_id).toBeTruthy();
|
|
|
|
// Simulate operator action: remove the container outside of kandev.
|
|
dockerRemove(before!.container_id!);
|
|
await expect
|
|
.poll(() => dockerInspectExists(before!.container_id!), { timeout: 10_000 })
|
|
.toBe(false);
|
|
|
|
await apiClient.launchSession({
|
|
task_id: task.id,
|
|
agent_profile_id: seedData.agentProfileId,
|
|
executor_profile_id: seedData.dockerExecutorProfileId,
|
|
prompt: "/e2e:simple-message",
|
|
auto_start: true,
|
|
});
|
|
|
|
await waitForLatestSessionDone(apiClient, task.id, 2, "Waiting for recovery session");
|
|
const after = await apiClient.getTaskEnvironment(task.id);
|
|
expect(after!.container_id, "must record a container_id on recovery").toBeTruthy();
|
|
expect(after!.container_id).not.toBe(before!.container_id);
|
|
expect(dockerInspectExists(after!.container_id!), "recovery container should exist").toBe(true);
|
|
});
|
|
});
|