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); + }); +});