From 81374bee2227f9db034896375e865597d4707635 Mon Sep 17 00:00:00 2001 From: Manthan Thakar Date: Sun, 8 Mar 2026 18:22:31 -0700 Subject: [PATCH] fix: auto-close session when queue owner exits and agent has exited (#47) When the queue owner shuts down (TTL expired, no more tasks), mark the session as closed if the agent process has already exited. This prevents zombie session records from accumulating in ~/.acpx/sessions/ with closed=false despite the agent being dead. --- src/session-runtime.ts | 14 +++++++ test/integration.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) 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]; }