diff --git a/src/session-runtime.ts b/src/session-runtime.ts index e918a3a..92882f9 100644 --- a/src/session-runtime.ts +++ b/src/session-runtime.ts @@ -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`); } diff --git a/test/integration.test.ts b/test/integration.test.ts index 526eef5..e0b194c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -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]; }