Skip to content

Commit fe65e5a

Browse files
fix: subagent deallocation, compaction output retention, TUI listener leaks
Addresses the 4 remaining high-priority memory leak issues from the work plan (I-9385-A, I-7046-A, PR-14635, I-7046-C partial). I-9385-A (CRITICAL, Priority anomalyco#1) — tool/task.ts: call Session.remove() after extracting subagent task output. This fires the session.deleted event, which triggers cleanupSessionCaches() in the event-reducer — freeing all in-memory messages, parts, diffs, permissions, and status for the subagent session. The task_id in the output becomes a dead reference; if the LLM tries to resume, Session.get() fails gracefully and a fresh session is created. Validated: the cleanup infrastructure already existed but was never invoked for subagent sessions. I-7046-A (CRITICAL, Priority anomalyco#3) — session/compaction.ts: clear part.state.output and part.state.attachments when pruning compacted tool parts. Previously, prune() set time.compacted but left the full output string in both the DB row and the in-memory store. toModelMessages already substituted "[Old tool result content cleared]" for compacted parts — this change aligns stored data with that behavior, freeing the large strings from memory and disk. PR-14635 (HIGH, Priority anomalyco#4) — TUI event listener cleanup: - app.tsx: save the unsubscribe functions returned by all 6 sdk.event.on() calls; call them in a single onCleanup() handler. Previously, onCleanup was not even imported. - routes/session/index.tsx: save and clean up the message.part.updated listener. This component mounts/unmounts during session navigation, so each navigation previously added a duplicate listener. - component/prompt/index.tsx: save and clean up the PromptAppend listener. Same mount/unmount pattern as the session component. I-7046-C (partial) — the TUI event listener fixes above cover the most impactful instances of the missing-dispose pattern. A full audit of all subscribe() call sites remains as follow-up work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d0f1c66 commit fe65e5a

5 files changed

Lines changed: 32 additions & 9 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
33
import { Selection } from "@tui/util/selection"
44
import { MouseButton, TextAttributes } from "@opentui/core"
55
import { RouteProvider, useRoute } from "@tui/context/route"
6-
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
6+
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, onCleanup, batch, Show, on } from "solid-js"
77
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
88
import { Installation } from "@/installation"
99
import { Flag } from "@/flag/flag"
@@ -668,11 +668,11 @@ function App() {
668668
}
669669
})
670670

671-
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
671+
const unsub1 = sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
672672
command.trigger(evt.properties.command)
673673
})
674674

675-
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
675+
const unsub2 = sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
676676
toast.show({
677677
title: evt.properties.title,
678678
message: evt.properties.message,
@@ -681,14 +681,14 @@ function App() {
681681
})
682682
})
683683

684-
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
684+
const unsub3 = sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
685685
route.navigate({
686686
type: "session",
687687
sessionID: evt.properties.sessionID,
688688
})
689689
})
690690

691-
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
691+
const unsub4 = sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
692692
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
693693
route.navigate({ type: "home" })
694694
toast.show({
@@ -698,7 +698,7 @@ function App() {
698698
}
699699
})
700700

701-
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
701+
const unsub5 = sdk.event.on(SessionApi.Event.Error.type, (evt) => {
702702
const error = evt.properties.error
703703
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
704704
const message = (() => {
@@ -720,7 +720,7 @@ function App() {
720720
})
721721
})
722722

723-
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
723+
const unsub6 = sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
724724
toast.show({
725725
variant: "info",
726726
title: "Update Available",
@@ -729,6 +729,15 @@ function App() {
729729
})
730730
})
731731

732+
onCleanup(() => {
733+
unsub1()
734+
unsub2()
735+
unsub3()
736+
unsub4()
737+
unsub5()
738+
unsub6()
739+
})
740+
732741
return (
733742
<box
734743
width={dimensions().width}

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export function Prompt(props: PromptProps) {
9696
const pasteStyleId = syntax().getStyleId("extmark.paste")!
9797
let promptPartTypeId = 0
9898

99-
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
99+
const unsubPromptAppend = sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
100100
if (!input || input.isDestroyed) return
101101
input.insertText(evt.properties.text)
102102
setTimeout(() => {
@@ -107,6 +107,7 @@ export function Prompt(props: PromptProps) {
107107
renderer.requestRender()
108108
}, 0)
109109
})
110+
onCleanup(unsubPromptAppend)
110111

111112
createEffect(() => {
112113
if (props.disabled) input.cursorColor = theme.backgroundElement

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
For,
88
Match,
99
on,
10+
onCleanup,
1011
Show,
1112
Switch,
1213
useContext,
@@ -204,7 +205,7 @@ export function Session() {
204205
})
205206

206207
let lastSwitch: string | undefined = undefined
207-
sdk.event.on("message.part.updated", (evt) => {
208+
const unsubPartUpdated = sdk.event.on("message.part.updated", (evt) => {
208209
const part = evt.properties.part
209210
if (part.type !== "tool") return
210211
if (part.sessionID !== route.sessionID) return
@@ -219,6 +220,7 @@ export function Session() {
219220
lastSwitch = part.id
220221
}
221222
})
223+
onCleanup(unsubPartUpdated)
222224

223225
let scroll: ScrollBoxRenderable
224226
let prompt: PromptRef

packages/opencode/src/session/compaction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export namespace SessionCompaction {
9191
for (const part of toPrune) {
9292
if (part.state.status === "completed") {
9393
part.state.time.compacted = Date.now()
94+
// Clear tool output and attachments from both DB and in-memory store.
95+
// toModelMessages already substitutes "[Old tool result content cleared]"
96+
// for compacted parts, so this aligns stored data with model behavior.
97+
part.state.output = "[compacted]"
98+
part.state.attachments = undefined
9499
await Session.updatePart(part)
95100
}
96101
}

packages/opencode/src/tool/task.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ export const TaskTool = Tool.define("task", async (ctx) => {
152152
"</task_result>",
153153
].join("\n")
154154

155+
// Clean up subagent session to free in-memory state (messages, parts,
156+
// event listeners). The task output has already been captured above.
157+
// If the LLM later tries to resume via task_id, Session.get() will
158+
// fail gracefully and a fresh session will be created instead.
159+
Session.remove(session.id).catch(() => {})
160+
155161
return {
156162
title: params.description,
157163
metadata: {

0 commit comments

Comments
 (0)