Skip to content
21 changes: 21 additions & 0 deletions supervisor/src/docker-stop-state.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
9 changes: 7 additions & 2 deletions supervisor/src/docker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Docker from "dockerode";
import { wasRunningBeforeStop } from "./stop-state";
import {
BOT_LABEL,
type ComputerNames,
Expand Down Expand Up @@ -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<boolean> {
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 });
Expand All @@ -1176,7 +1179,9 @@ export async function stop(names: ComputerNames): Promise<boolean> {
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;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions supervisor/src/stop-state.ts
Original file line number Diff line number Diff line change
@@ -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";
}
4 changes: 2 additions & 2 deletions supervisor/tests/docker.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions supervisor/tests/fixtures/docker-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading