Skip to content

agentmemory stop in Docker mode signals the wrong PID — can kill Docker Desktop's backend entirely, and separately tears down unrelated compose services #1151

Description

@pwflint

agentmemory stop in Docker mode signals the wrong PID — can kill Docker Desktop's backend entirely, and separately tears down unrelated compose services

Version: 0.9.27
Platform: macOS (Docker Desktop 29.5.2), Apple Silicon
Config: AGENTMEMORY_USE_DOCKER=1, agentmemory running as one service inside a larger multi-service docker-compose.yml (alongside unrelated services like a CI runner and other containers)

Summary

Three related bugs in the Docker-mode engine lifecycle, found together while debugging why agentmemory stop took down Docker Desktop itself:

  1. adoptRunningEngine() mislabels a Docker-managed engine as kind: "native", causing stop to SIGTERM whatever process happens to be listening on the REST port.
  2. When the correct Docker path is taken, stopDockerEngine() runs an unscoped docker compose down, which tears down every service in the compose file, not just agentmemory.
  3. stopDockerEngine() never signals the native worker process — it deletes worker.pid without stopping the process, leaking orphaned workers on every stop.

Bug 1 — engine misidentified as native, wrong PID gets signaled

adoptRunningEngine() (dist/cli.mjs) does this when it finds the engine already running and no persisted state:

function adoptRunningEngine() {
	try {
		const existingState = readEngineState();
		const existingPid = readEnginePidfile();
		if (existingState && existingPid) return;
		const enginePid = findEnginePidsByPort(getRestPort())[0];
		if (enginePid && !existingPid) writeEnginePidfile(enginePid);
		if (!existingState) writeEngineState({
			kind: "native",
			configPath: findIiiConfig() || "",
			attached: true
		});
		...

It unconditionally records kind: "native" and pidfiles whatever PID is listening on the REST port — it never checks whether that process is actually an iii/agentmemory process. On macOS with Docker Desktop, the host side of a published container port is held by com.docker.backend (Docker's own port-forwarding process), not by anything inside the container. So findEnginePidsByPort() returns Docker Desktop's backend PID, and it gets written straight into iii.pid.

Later, agentmemory stop reads that pidfile and does:

for (const pid of candidates) {
    ...
    s.start(`Stopping iii-engine (pid ${pid})...`);
    const ok = await signalAndWait(pid, "SIGTERM", 3e3);

SIGTERM to com.docker.backend kills Docker Desktop's backend daemon outright — /Users/<user>/.docker/run/docker.sock disappears, docker info fails, and every container on the machine goes down, not just agentmemory's.

I confirmed AGENTMEMORY_USE_DOCKER also isn't actually present in process.env at the point adoptRunningEngine() runs (no dotenv load happens before it in main()), so a fix can't just gate on that env var — the check needs to identify the process itself, e.g. via ps -p <pid> -o comm= and verifying it's actually iii/agentmemory before ever treating it as a killable native engine.

Bug 2 — stopDockerEngine() runs unscoped down, takes out unrelated services

Once state.kind === "docker" is correctly reached, stopDockerEngine() does:

const ok = runCommand(dockerBin, [
	"compose", "-f", composeFile, "down"
], { label: `docker compose -f ${composeFile} down` });

For anyone running agentmemory as one service inside a larger shared docker-compose.yml (a very normal setup — AGENTMEMORY_USE_DOCKER docs don't say the compose file has to be agentmemory-only), an unscoped down stops and removes every container defined in that file. In my case that took down four completely unrelated services (a CI runner, a web app, a tunnel container, a chat UI) along with agentmemory. Named volumes survive (no -v flag), so it's recoverable via docker compose up -d, but it's a surprising and disruptive blast radius for a command scoped to "stop agentmemory."

Suggested fix: scope to the service, e.g. docker compose -f <file> stop agentmemory (stop, not down — avoids removing containers/networks too).

Bug 3 — native worker process never actually stopped

Still inside stopDockerEngine():

clearEnginePidfile();
clearEngineState();
clearWorkerPidfile();

This clears worker.pid but never signals the PID it names. The native worker process (the long-running Node process that registers with the engine and serves the viewer) is simply orphaned — still running, just untracked. Over a session of repeated agentmemory / agentmemory stop cycles in Docker mode, this leaks one worker process per cycle. The native (non-Docker) stop path already has the right pattern for this (signal workerPid via signalAndWait before touching the engine) — stopDockerEngine() just doesn't do it.

Repro

# ~/.agentmemory/.env
AGENTMEMORY_USE_DOCKER=1
AGENTMEMORY_URL=http://127.0.0.1:3115
III_REST_PORT=3115
# docker-compose.yml with agentmemory as one of several services
docker compose up -d agentmemory
agentmemory                 # attaches, writes iii.pid = pid of com.docker.backend
cat ~/.agentmemory/engine-state.json   # {"kind":"native", ...}  <-- should be "docker"
agentmemory stop            # SIGTERMs com.docker.backend
docker info                 # Cannot connect to the Docker daemon

Patch I'm running locally (all three fixes)

Happy to open a PR with these if useful — sharing the approach here first in case there's context I'm missing about why kind defaults to native on attach.

1. adoptRunningEngine() — verify process identity before trusting a PID:

function looksLikeIiiEngine(pid) {
	try {
		const comm = execFileSync("ps", ["-p", String(pid), "-o", "comm="], { encoding: "utf-8" }).trim();
		const base = comm.split("/").pop() ?? comm;
		return base === "iii" || base.startsWith("iii-") || /agentmemory/i.test(base);
	} catch {
		return false;
	}
}

function adoptRunningEngine() {
	try {
		const existingState = readEngineState();
		const existingPid = readEnginePidfile();
		if (existingState && existingPid) return;
		const enginePid = findEnginePidsByPort(getRestPort())[0];
		if (enginePid && !looksLikeIiiEngine(enginePid)) {
			const composeFile = discoverComposeFile();
			if (existingPid) clearEnginePidfile();
			if (!existingState) writeEngineState({
				kind: "docker",
				composeFile,
				attached: true
			});
			return;
		}
		if (enginePid && !existingPid) writeEnginePidfile(enginePid);
		if (!existingState) writeEngineState({
			kind: "native",
			configPath: findIiiConfig() || "",
			attached: true
		});
		if (enginePid && !existingPid) p.log.info(`Attached to existing iii-engine (pid ${enginePid})`);
	} catch (err) {
		vlog(`adoptRunningEngine: ${err instanceof Error ? err.message : String(err)}`);
	}
}

(Note: discoverComposeFile()'s search order — package dir, then process.cwd() — won't reliably find a compose file that lives elsewhere, e.g. a dedicated infra repo the CLI isn't invoked from. Worth considering an explicit AGENTMEMORY_COMPOSE_FILE env var so adoptRunningEngine() can point at the right file regardless of cwd, rather than relying only on cwd/package-relative discovery.)

2 & 3. stopDockerEngine() — scope to the service, and actually stop the worker:

async function stopDockerEngine(composeFile, port) {
	const dockerBin = whichBinary("docker");
	if (!dockerBin) {
		p.log.error(`Engine was started via Docker compose, but \`docker\` is no longer on PATH. Stop it manually:\n  docker compose -f ${composeFile} stop agentmemory`);
		process.exit(1);
	}
	if (!existsSync(composeFile)) {
		p.log.error(`Engine state references ${composeFile}, but the file is gone. Stop it manually:\n  docker compose stop agentmemory  (from the dir holding the original docker-compose.yml)`);
		process.exit(1);
	}
	const workerPid = readWorkerPidfile();
	if (workerPid) {
		const s = p.spinner();
		s.start(`Stopping agentmemory worker (pid ${workerPid})... [flushing state]`);
		const workerOk = await signalAndWait(workerPid, "SIGTERM", 5e3);
		s.stop(workerOk ? `Stopped worker pid ${workerPid}` : `Failed to stop worker pid ${workerPid}`);
	}
	const ok = runCommand(dockerBin, [
		"compose", "-f", composeFile, "stop", "agentmemory"
	], { label: `docker compose -f ${composeFile} stop agentmemory` });
	clearEnginePidfile();
	clearEngineState();
	clearWorkerPidfile();
	if (!ok) {
		p.log.error(`docker compose stop failed. The engine may still be running on :${port}. Inspect with:\n  docker compose -f ${composeFile} ps`);
		process.exit(1);
	}
	p.outro("Stopped. Memories persisted to disk; restart anytime with: npx @agentmemory/agentmemory");
}

Verified locally: agentmemory stop now only stops the agentmemory container, Docker Desktop's backend and every unrelated compose service stay up, and the native worker process actually terminates instead of leaking.

Happy to turn this into a PR against the pinned engine/CLI if that's preferred over a maintainer picking it up — let me know which you'd rather have.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions