test(docker): cover stop + resume → same container, branch preserved

Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.

Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.
This commit is contained in:
José Almeida
2026-05-05 23:05:22 +01:00
parent b1b20b8cb7
commit d35e601d42
3 changed files with 85 additions and 3 deletions
@@ -399,7 +399,7 @@ function PrepareStatusSection({ summary }: { summary: PrepareSummary }) {
<div
data-testid="executor-prepare-status"
data-phase={summary.phase}
className="border-b border-border p-3 space-y-1"
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2">
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
@@ -428,7 +428,7 @@ function PrepareStatusSection({ summary }: { summary: PrepareSummary }) {
<div
data-testid="executor-prepare-status"
data-phase="failed"
className="border-b border-border p-3 space-y-1"
className="border-b border-border px-3 py-2.5 space-y-1"
>
<div className="flex items-center gap-2 text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5" />
@@ -500,7 +500,7 @@ function EnvironmentInfo({
}
return (
<div className="p-3 space-y-3">
<div className="px-3 pt-2.5 pb-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground">{formatExecutorType(env.executor_type)}</span>
<StatusBadge env={env} container={container} />
+9
View File
@@ -839,6 +839,15 @@ export class ApiClient {
return this.wsRequest("session.launch", payload);
}
/** Stop a running session via WS `session.stop` — same path the UI uses. */
async stopSession(payload: {
session_id: string;
reason?: string;
force?: boolean;
}): Promise<{ success: boolean }> {
return this.wsRequest("session.stop", payload);
}
async getTaskEnvironment(taskId: string): Promise<{
id: string;
task_id: string;
@@ -565,6 +565,79 @@ test.describe("Docker executor — launch + reuse + recovery", () => {
expect(launchedSession?.task_environment_id).toBe(before!.id);
});
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