From ed6300b35ef176c5c2ec84a0b84b5a50a1e1994d Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 8 Aug 2026 09:42:35 +0000 Subject: [PATCH] fix(agent): release parked subagent session memory on dispose Keep-alive subagents are handed to AgentLifecycleManager.adopt, which stores their reviver closure in the process-global #adopted map. The closure is defined inside runSubagent's scope, which also captures the live AgentSession (extension-runner callbacks, buildSubagentSessionOptions), so its lexical environment pins the whole session graph. park() disposes and detaches the session but leaves the adoption record indefinitely, and #doDispose never dropped the in-memory transcript, session-manager entries, or the raw-SSE debug buffer (whose trimmed records retain slice() views of full wire frames), so every completed subagent's heavy state leaked for the process lifetime. dispose() is terminal and every revival path reopens from disk, so #doDispose now sheds retained conversation memory via agent.reset(), RawSseDebugBuffer.clear(), and SessionManager.releaseRetainedEntries(). The adoption record can still reference the session, but only as a husk. Fixes #8003 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/debug/raw-sse-buffer.ts | 20 ++++ .../coding-agent/src/session/agent-session.ts | 10 ++ .../src/session/session-manager.ts | 14 +++ ...nt-session-dispose-releases-memory.test.ts | 93 +++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cb7c55d0..8ccc452e1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed long-running sessions leaking memory for every completed keep-alive `task`/scout subagent: a disposed (parked) subagent's `AgentSession` stayed pinned through the lifecycle adoption record's reviver closure, and `dispose()` never released the message array, session-manager entries, or the raw-SSE debug buffer, so heavy transcripts and captured provider wire frames accumulated for the process lifetime ([#8003](https://github.com/can1357/oh-my-pi/issues/8003)). + ## [17.2.11] - 2026-08-07 ### Added diff --git a/packages/coding-agent/src/debug/raw-sse-buffer.ts b/packages/coding-agent/src/debug/raw-sse-buffer.ts index 3490b4b4b..8bd6fad89 100644 --- a/packages/coding-agent/src/debug/raw-sse-buffer.ts +++ b/packages/coding-agent/src/debug/raw-sse-buffer.ts @@ -335,6 +335,26 @@ export class RawSseDebugBuffer { return body.length > 0 ? `${dropped}${body}` : dropped; } + /** + * Drop every retained record and reset accounting. Called from + * {@link AgentSession} teardown so a disposed (e.g. parked subagent) session + * stops pinning captured wire frames — each trimmed record holds a + * `slice()` of its parent SSE frame, which under JSC keeps the whole + * multi-MB frame alive. Notifies subscribers so a live debug viewer redraws + * empty. + */ + clear(): void { + this.#records = []; + this.#recordChars = []; + this.#head = 0; + this.#totalChars = 0; + this.#droppedRecords = 0; + this.#droppedChars = 0; + this.#totalEvents = 0; + this.#lastUpdatedAt = undefined; + this.#emit(); + } + #append(record: RawSseDebugRecord, chars: number): void { this.#records.push(record); this.#recordChars.push(chars); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a2ae42c32..fe52b58e2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3851,6 +3851,16 @@ export class AgentSession { } this.#eventListeners = []; this.#sessionChangeCallbacks.clear(); + + // Release retained conversation memory. dispose() is terminal, and every + // revival path reopens the transcript from disk (AgentLifecycleManager + // reviver / persisted-revive / `history://`), so the in-memory copy is + // dead weight from here on. Dropping it lets a parked subagent's session + // graph shed its heavy payloads even while the lifecycle adoption record's + // reviver closure still references the session object. Fixes #8003. + this.agent.reset(); + this.rawSseDebugBuffer.clear(); + this.sessionManager.releaseRetainedEntries(); } #closeAllProviderSessions(reason: string): void { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 5a311c508..e99844d8a 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1707,6 +1707,20 @@ export class SessionManager { if (this.#diskFailure) throw this.#diskFailure; } + /** + * Drop the in-memory transcript after a terminal {@link close}. The entry + * journal and its index mirror the agent's message array (tool results, + * file contents, base64 frame images); on a disposed session — e.g. a + * parked subagent still referenced by the lifecycle adoption record — they + * would otherwise stay pinned for the process lifetime. Reads after this + * point reopen from disk (revival, `history://`), so releasing the + * in-memory copy is safe. Only call once, from session dispose. + */ + releaseRetainedEntries(): void { + this.#entries = []; + this.#index.clear(); + } + getCwd(): string { return this.#cwd; } diff --git a/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts b/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts new file mode 100644 index 000000000..b2d214fdb --- /dev/null +++ b/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +// Regression: a keep-alive subagent's AgentSession is disposed at park() but +// stays reachable through the lifecycle adoption record's reviver closure +// (which shares the runSubagent lexical environment that captured the live +// session). Before the fix, dispose() left the message array, session-manager +// entries, and the raw-SSE debug buffer intact, so every completed subagent +// pinned its full transcript + captured wire frames for the process lifetime. +// dispose() must now shed that heavy state so the pinned graph is only a husk. +// See issue #8003. +describe("AgentSession dispose releases retained memory", () => { + let tempDir: TempDir; + let authStorage: AuthStorage; + let session: AgentSession | undefined; + + beforeEach(async () => { + tempDir = TempDir.createSync("@omp-dispose-release-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + }); + + afterEach(async () => { + const current = session; + session = undefined; + if (current) await current.dispose(); + authStorage.close(); + AsyncJobManager.resetForTests(); + vi.restoreAllMocks(); + tempDir.removeSync(); + }); + + function createSession(): AgentSession { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("expected bundled model"); + const mock = createMockModel({ handler: () => ({ content: ["ok"] }) }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["test"], tools: [] }, + streamFn: mock.stream, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(tempDir.path()), + settings: Settings.isolated(), + modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")), + agentId: "Main", + }); + return session; + } + + it("clears messages, session entries, and the raw-SSE buffer on dispose", async () => { + const current = createSession(); + const bulk = "x".repeat(4096); + + const messages: AgentMessage[] = [ + { role: "user", content: [{ type: "text", text: bulk }], timestamp: Date.now() }, + ]; + current.agent.replaceMessages(messages); + current.sessionManager.appendMessage({ role: "user", content: bulk, timestamp: Date.now() }); + current.rawSseDebugBuffer.recordEvent( + { + event: "content_block_delta", + data: `data: ${bulk}`, + raw: ["event: content_block_delta", `data: ${bulk}`], + }, + current.agent.state.model, + ); + + // Precondition: the heavy state is actually present before dispose. + expect(current.agent.state.messages.length).toBeGreaterThan(0); + expect(current.sessionManager.getEntries().length).toBeGreaterThan(0); + expect(current.rawSseDebugBuffer.toRawText().length).toBeGreaterThan(0); + + await current.dispose(); + session = undefined; + + expect(current.agent.state.messages).toHaveLength(0); + expect(current.sessionManager.getEntries()).toHaveLength(0); + expect(current.rawSseDebugBuffer.toRawText()).toBe(""); + expect(current.rawSseDebugBuffer.snapshot().records).toHaveLength(0); + }); +});