diff --git a/supervisor/src/docker-stop-state.test.ts b/supervisor/src/docker-stop-state.test.ts new file mode 100644 index 000000000..6b87d3ca0 --- /dev/null +++ b/supervisor/src/docker-stop-state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { wasRunningBeforeStop } from "./stop-state"; + +describe("Computer Stop state classification", () => { + test("reports only a running container as wasRunning", () => { + expect(wasRunningBeforeStop("running")).toBe(true); + expect(wasRunningBeforeStop("RUNNING")).toBe(true); + + for (const status of [ + "created", + "restarting", + "paused", + "removing", + "exited", + "dead", + "unknown", + ]) { + expect(wasRunningBeforeStop(status)).toBe(false); + } + }); +}); diff --git a/supervisor/src/docker.ts b/supervisor/src/docker.ts index 74a8f6f2c..d76ea0468 100644 --- a/supervisor/src/docker.ts +++ b/supervisor/src/docker.ts @@ -1,4 +1,5 @@ import Docker from "dockerode"; +import { wasRunningBeforeStop } from "./stop-state"; import { BOT_LABEL, type ComputerNames, @@ -1166,7 +1167,9 @@ export async function ensure( /** Stop this Bot's computer. Its storage is untouched, so its logins survive. */ export async function stop(names: ComputerNames): Promise { - if (!(await inspectOwned(names))) return false; + const existing = await inspectOwned(names); + if (!existing) return false; + const wasRunning = wasRunningBeforeStop(existing.status); try { // Long enough for Chromium to flush its profile, matching the compose grace period. await docker.getContainer(names.container).stop({ t: 30 }); @@ -1176,7 +1179,9 @@ export async function stop(names: ComputerNames): Promise { throw new DockerUnavailableError(String(error)); } } - return true; + // The gateway/audit contract is "was it running before this Stop?", not "did a container exist?". + // Docker keeps stopped containers so existence alone would report a second idempotent Stop as work. + return wasRunning; } /** diff --git a/supervisor/src/stop-state.ts b/supervisor/src/stop-state.ts new file mode 100644 index 000000000..57068d82f --- /dev/null +++ b/supervisor/src/stop-state.ts @@ -0,0 +1,4 @@ +/** The gateway's `wasRunning` flag describes active state before Stop, not container existence. */ +export function wasRunningBeforeStop(status: string): boolean { + return status.toLowerCase() === "running"; +} diff --git a/supervisor/tests/docker.integration.test.ts b/supervisor/tests/docker.integration.test.ts index 9c5afa169..92749aa3b 100644 --- a/supervisor/tests/docker.integration.test.ts +++ b/supervisor/tests/docker.integration.test.ts @@ -16,7 +16,7 @@ async function available() { } test.skipIf(!(await available()))( - "supervisor Docker lifecycle in an isolated namespace (nine cases)", + "supervisor Docker lifecycle in an isolated namespace (ten cases)", async () => { const namespace = `supervisor-test-${crypto.randomUUID()}`; const child = Bun.spawn( @@ -49,7 +49,7 @@ test.skipIf(!(await available()))( if (!summaryLine) throw new Error(`Missing fixture result: ${stdout} ${stderr}`); const summary = JSON.parse(summaryLine.slice(prefix.length)); - expect(summary.completedCases).toBe(9); + expect(summary.completedCases).toBe(10); expect(summary.cleanup).toBe("complete"); // Do not echo nested Bun summaries: scripts/test-ci.ts counts the outer suite's summary. console.log(summaryLine); diff --git a/supervisor/tests/fixtures/docker-lifecycle.ts b/supervisor/tests/fixtures/docker-lifecycle.ts index eb3c2ff53..8dcc37594 100644 --- a/supervisor/tests/fixtures/docker-lifecycle.ts +++ b/supervisor/tests/fixtures/docker-lifecycle.ts @@ -295,6 +295,23 @@ describe("fleet lifecycle timestamps", () => { }, 90_000); }); +describe("idempotent Stop result", () => { + test("reports true only when the Computer was running before Stop", async () => { + await withDocker().supervisor.ensure(names, { + image: IMAGE, + environment: [], + }); + + expect(await withDocker().supervisor.stop(names)).toBe(true); + expect(await withDocker().supervisor.stop(names)).toBe(false); + + const inspected = await withDocker() + .docker.getContainer(names.container) + .inspect(); + expect(inspected.State?.Running).toBe(false); + }, 90_000); +}); + describe("a computer that never answers", () => { test("fails instead of being handed out as ready", async () => { // A wait that cannot fail is a sleep: every computer that never came up was reported ready, and