Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,20 @@ export async function runSessionQueueOwner(options: QueueOwnerRuntimeOptions): P
await owner.close();
}
await releaseQueueOwnerLease(lease);

// Auto-close the session when the queue owner shuts down and the agent
// has exited, preventing zombie session accumulation (#47).
try {
const record = await resolveSessionRecord(options.sessionId);
if (!record.closed && record.lastAgentExitAt) {
record.closed = true;
record.closedAt = isoNow();
await writeSessionRecord(record);
}
} catch {
// best effort — session may already be cleaned up
}

if (options.verbose) {
process.stderr.write(`[acpx] queue owner stopped for session ${options.sessionId}\n`);
}
Expand Down
87 changes: 87 additions & 0 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,93 @@ test("integration: prompt exits after done while detached owner stays warm", asy
});
});

test("integration: session auto-closes when queue owner exits and agent has exited (#47)", async () => {
await withTempHome(async (homeDir) => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-integration-cwd-"));

try {
// 1. Create a persistent session
const created = await runCli(
[...baseAgentArgs(cwd), "--format", "json", "sessions", "new"],
homeDir,
);
assert.equal(created.code, 0, created.stderr);
const createdPayload = JSON.parse(created.stdout.trim()) as {
acpxRecordId?: string;
};
const sessionId = createdPayload.acpxRecordId;
assert.equal(typeof sessionId, "string");

// 2. Send a prompt with a very short TTL so the queue owner exits quickly
const prompt = await runCli(
[...baseAgentArgs(cwd), "--format", "quiet", "--ttl", "1", "prompt", "echo oneshot-done"],
homeDir,
);
assert.equal(prompt.code, 0, prompt.stderr);
assert.match(prompt.stdout, /oneshot-done/);

// 3. Wait for the queue owner to exit (it should exit after 1s TTL)
const { lockPath } = queuePaths(homeDir, sessionId as string);
let ownerPid: number | undefined;
try {
const lockPayload = JSON.parse(await fs.readFile(lockPath, "utf8")) as {
pid?: number;
};
ownerPid = lockPayload.pid;
} catch {
// lock file may already be gone
}

if (typeof ownerPid === "number") {
assert.equal(await waitForPidExit(ownerPid, 10_000), true, "queue owner did not exit");
}

// Give a moment for final writes
await sleep(500);

// 4. Read the session record from disk
const recordPath = path.join(
homeDir,
".acpx",
"sessions",
`${encodeURIComponent(sessionId as string)}.json`,
);
const storedRecord = JSON.parse(await fs.readFile(recordPath, "utf8")) as {
closed?: boolean;
closed_at?: string;
last_agent_exit_at?: string;
last_agent_exit_code?: number | null;
};

// 5. After the fix for #47, the queue owner auto-closes the session
// when it shuts down and the agent has exited.
assert.equal(
storedRecord.last_agent_exit_at != null,
true,
"expected last_agent_exit_at to be set (agent has exited)",
);

assert.equal(
storedRecord.closed,
true,
"session should be auto-closed after agent exit and queue owner shutdown (#47)",
);

assert.equal(
typeof storedRecord.closed_at,
"string",
"closed_at should be set when session is auto-closed",
);
} finally {
// Clean up: close session if it's still around
await runCli([...baseAgentArgs(cwd), "--format", "json", "sessions", "close"], homeDir).catch(
() => {},
);
await fs.rm(cwd, { recursive: true, force: true });
}
});
});

function baseAgentArgs(cwd: string): string[] {
return ["--agent", MOCK_AGENT_COMMAND, "--approve-all", "--cwd", cwd];
}
Expand Down