Merge pull request #2458 from code-yeongyu/fix/memory-leaks

fix: resolve 12 memory leaks (3 critical + 9 high)
This commit is contained in:
YeonGyu-Kim
2026-03-12 11:21:13 +09:00
committed by GitHub
51 changed files with 2883 additions and 262 deletions
+22 -1
View File
@@ -11,6 +11,20 @@ import { createSkillHooks } from "./plugin/hooks/create-skill-hooks"
export type CreatedHooks = ReturnType<typeof createHooks>
type DisposableHook = { dispose?: () => void } | null | undefined
export type DisposableCreatedHooks = {
runtimeFallback?: DisposableHook
todoContinuationEnforcer?: DisposableHook
autoSlashCommand?: DisposableHook
}
export function disposeCreatedHooks(hooks: DisposableCreatedHooks): void {
hooks.runtimeFallback?.dispose?.()
hooks.todoContinuationEnforcer?.dispose?.()
hooks.autoSlashCommand?.dispose?.()
}
export function createHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
@@ -58,9 +72,16 @@ export function createHooks(args: {
availableSkills,
})
return {
const hooks = {
...core,
...continuation,
...skill,
}
return {
...hooks,
disposeHooks: (): void => {
disposeCreatedHooks(hooks)
},
}
}
+2 -2
View File
@@ -53,8 +53,8 @@ export function createManagers(args: {
log("[index] onSubagentSessionCreated callback completed")
},
onShutdown: () => {
tmuxSessionManager.cleanup().catch((error) => {
onShutdown: async () => {
await tmuxSessionManager.cleanup().catch((error) => {
log("[index] tmux cleanup error during shutdown:", error)
})
},
@@ -0,0 +1,193 @@
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach, describe, expect, test } from "bun:test"
import { ConcurrencyManager } from "./concurrency"
import { BackgroundManager } from "./manager"
import type { BackgroundTask, LaunchInput } from "./types"
const managersToShutdown: BackgroundManager[] = []
afterEach(() => {
while (managersToShutdown.length > 0) managersToShutdown.pop()?.shutdown()
})
function createBackgroundManager(config?: { defaultConcurrency?: number }): BackgroundManager {
const directory = tmpdir()
const client = { session: {} as PluginInput["client"]["session"] } as PluginInput["client"]
Reflect.set(client.session, "abort", async () => ({ data: true }))
Reflect.set(client.session, "create", async () => ({ data: { id: `session-${crypto.randomUUID().slice(0, 8)}` } }))
Reflect.set(client.session, "get", async () => ({ data: { directory } }))
Reflect.set(client.session, "messages", async () => ({ data: [] }))
Reflect.set(client.session, "prompt", async () => ({ data: { info: {}, parts: [] } }))
Reflect.set(client.session, "promptAsync", async () => ({ data: undefined }))
const manager = new BackgroundManager({
$: {} as PluginInput["$"],
client,
directory,
project: {} as PluginInput["project"],
serverUrl: new URL("http://localhost"),
worktree: directory,
}, config)
managersToShutdown.push(manager)
return manager
}
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; parentSessionID: string }): BackgroundTask {
return {
id: overrides.id,
sessionID: overrides.sessionID,
parentSessionID: overrides.parentSessionID,
parentMessageID: overrides.parentMessageID ?? "parent-message-id",
description: overrides.description ?? "test task",
prompt: overrides.prompt ?? "test prompt",
agent: overrides.agent ?? "test-agent",
status: overrides.status ?? "running",
queuedAt: overrides.queuedAt,
startedAt: overrides.startedAt ?? new Date(),
completedAt: overrides.completedAt,
error: overrides.error,
model: overrides.model,
concurrencyKey: overrides.concurrencyKey,
concurrencyGroup: overrides.concurrencyGroup,
progress: overrides.progress,
}
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> { return Reflect.get(manager, "tasks") as Map<string, BackgroundTask> }
function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>> { return Reflect.get(manager, "pendingByParent") as Map<string, Set<string>> }
function getQueuesByKey(manager: BackgroundManager): Map<string, Array<{ task: BackgroundTask; input: LaunchInput }>> { return Reflect.get(manager, "queuesByKey") as Map<string, Array<{ task: BackgroundTask; input: LaunchInput }>> }
function getConcurrencyManager(manager: BackgroundManager): ConcurrencyManager { return Reflect.get(manager, "concurrencyManager") as ConcurrencyManager }
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> { return Reflect.get(manager, "completionTimers") as Map<string, ReturnType<typeof setTimeout>> }
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
const processKey = Reflect.get(manager, "processKey") as (key: string) => Promise<void>
await processKey.call(manager, key)
}
function runScheduledCleanup(manager: BackgroundManager, taskId: string): void {
const timer = getCompletionTimers(manager).get(taskId)
if (!timer) {
throw new Error(`Expected cleanup timer for task ${taskId}`)
}
const onTimeout = Reflect.get(timer, "_onTimeout") as (() => void) | undefined
if (!onTimeout) {
throw new Error(`Expected cleanup callback for task ${taskId}`)
}
onTimeout()
}
describe("BackgroundManager.cancelTask cleanup", () => {
test("#given a running task in BackgroundManager #when cancelTask called with skipNotification=true #then task is eventually removed from this.tasks Map", async () => {
// given
const manager = createBackgroundManager()
const task = createMockTask({
id: "task-skip-notification-cleanup",
parentSessionID: "parent-session-skip-notification-cleanup",
sessionID: "session-skip-notification-cleanup",
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
// when
const cancelled = await manager.cancelTask(task.id, {
skipNotification: true,
source: "test",
})
// then
expect(cancelled).toBe(true)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
})
test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => {
// given
const manager = createBackgroundManager()
const task = createMockTask({
id: "task-notify-cleanup",
parentSessionID: "parent-session-notify-cleanup",
sessionID: "session-notify-cleanup",
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
// when
const cancelled = await manager.cancelTask(task.id, {
skipNotification: false,
source: "test",
})
// then
expect(cancelled).toBe(true)
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
})
test("#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start", async () => {
// given
const manager = createBackgroundManager({ defaultConcurrency: 1 })
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
await concurrencyManager.acquire(concurrencyKey)
const runningTask = createMockTask({
id: "task-running-before-cancel",
parentSessionID: "parent-session-concurrency-cleanup",
sessionID: "session-running-before-cancel",
concurrencyKey,
})
const pendingTask = createMockTask({
id: "task-pending-after-cancel",
parentSessionID: runningTask.parentSessionID,
status: "pending",
startedAt: undefined,
queuedAt: new Date(),
model: { providerID: "test-provider", modelID: "test-model" },
})
const queuedInput: LaunchInput = {
agent: pendingTask.agent,
description: pendingTask.description,
model: pendingTask.model,
parentMessageID: pendingTask.parentMessageID,
parentSessionID: pendingTask.parentSessionID,
prompt: pendingTask.prompt,
}
getTaskMap(manager).set(runningTask.id, runningTask)
getTaskMap(manager).set(pendingTask.id, pendingTask)
getPendingByParent(manager).set(runningTask.parentSessionID, new Set([runningTask.id, pendingTask.id]))
getQueuesByKey(manager).set(concurrencyKey, [{ input: queuedInput, task: pendingTask }])
Reflect.set(manager, "startTask", async ({ task }: { task: BackgroundTask; input: LaunchInput }) => {
task.status = "running"
task.startedAt = new Date()
task.sessionID = "session-started-after-cancel"
task.concurrencyKey = concurrencyKey
task.concurrencyGroup = concurrencyKey
})
// when
const cancelled = await manager.cancelTask(runningTask.id, {
abortSession: false,
skipNotification: true,
source: "test",
})
await processKeyForTest(manager, concurrencyKey)
// then
expect(cancelled).toBe(true)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(1)
expect(manager.getTask(pendingTask.id)?.status).toBe("running")
})
})
@@ -3785,7 +3785,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
manager.shutdown()
})
test("should start cleanup timers only after all tasks complete", async () => {
test("should start per-task cleanup timers independently of sibling completion", async () => {
// given
const client = {
session: {
@@ -3832,7 +3832,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
// then
const completionTimers = getCompletionTimers(manager)
expect(completionTimers.size).toBe(0)
expect(completionTimers.size).toBe(1)
// when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
+77 -30
View File
@@ -116,11 +116,12 @@ export class BackgroundManager {
private config?: BackgroundTaskConfig
private tmuxEnabled: boolean
private onSubagentSessionCreated?: OnSubagentSessionCreated
private onShutdown?: () => void
private onShutdown?: () => void | Promise<void>
private queuesByKey: Map<string, QueueItem[]> = new Map()
private processingKeys: Set<string> = new Set()
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private completedTaskSummaries: Map<string, Array<{id: string, description: string}>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private rootDescendantCounts: Map<string, number>
@@ -133,7 +134,7 @@ export class BackgroundManager {
options?: {
tmuxConfig?: TmuxConfig
onSubagentSessionCreated?: OnSubagentSessionCreated
onShutdown?: () => void
onShutdown?: () => void | Promise<void>
enableParentSessionNotifications?: boolean
}
) {
@@ -906,6 +907,13 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
this.scheduleTaskRemoval(task.id)
if (task.sessionID) {
SessionCategoryRegistry.remove(task.sessionID)
}
@@ -932,7 +940,12 @@ export class BackgroundManager {
this.pendingNotifications.delete(sessionID)
if (tasksToCancel.size === 0) return
if (tasksToCancel.size === 0) {
this.clearTaskHistoryWhenParentTasksGone(sessionID)
return
}
const parentSessionsToClear = new Set<string>()
const deletedSessionIDs = new Set<string>([sessionID])
for (const task of tasksToCancel.values()) {
@@ -942,6 +955,8 @@ export class BackgroundManager {
}
for (const task of tasksToCancel.values()) {
parentSessionsToClear.add(task.parentSessionID)
if (task.status === "running" || task.status === "pending") {
void this.cancelTask(task.id, {
source: "session.deleted",
@@ -959,6 +974,10 @@ export class BackgroundManager {
}
}
for (const parentSessionID of parentSessionsToClear) {
this.clearTaskHistoryWhenParentTasksGone(parentSessionID)
}
this.rootDescendantCounts.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
@@ -1125,6 +1144,39 @@ export class BackgroundManager {
}
}
private clearTaskHistoryWhenParentTasksGone(parentSessionID: string | undefined): void {
if (!parentSessionID) return
if (this.getTasksByParentSession(parentSessionID).length > 0) return
this.taskHistory.clearSession(parentSessionID)
this.completedTaskSummaries.delete(parentSessionID)
}
private scheduleTaskRemoval(taskId: string): void {
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(taskId)
}
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
const task = this.tasks.get(taskId)
if (task) {
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
log("[background-agent] Removed completed task from memory:", taskId)
this.clearTaskHistoryWhenParentTasksGone(task?.parentSessionID)
}
}, TASK_CLEANUP_DELAY_MS)
this.completionTimers.set(taskId, timer)
}
async cancelTask(
taskId: string,
options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }
@@ -1190,6 +1242,8 @@ export class BackgroundManager {
removeTaskToastTracking(task.id)
if (options?.skipNotification) {
this.cleanupPendingByParent(task)
this.scheduleTaskRemoval(task.id)
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1328,6 +1382,14 @@ export class BackgroundManager {
})
}
if (!this.completedTaskSummaries.has(task.parentSessionID)) {
this.completedTaskSummaries.set(task.parentSessionID, [])
}
this.completedTaskSummaries.get(task.parentSessionID)!.push({
id: task.id,
description: task.description,
})
// Update pending tracking and check if all tasks complete
const pendingSet = this.pendingByParent.get(task.parentSessionID)
let allComplete = false
@@ -1347,10 +1409,13 @@ export class BackgroundManager {
}
const completedTasks = allComplete
? Array.from(this.tasks.values())
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
? (this.completedTaskSummaries.get(task.parentSessionID) ?? [{ id: task.id, description: task.description }])
: []
if (allComplete) {
this.completedTaskSummaries.delete(task.parentSessionID)
}
const statusText = task.status === "completed"
? "COMPLETED"
: task.status === "interrupt"
@@ -1480,29 +1545,8 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
}
if (allComplete) {
for (const completedTask of completedTasks) {
const taskId = completedTask.id
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
this.completionTimers.delete(taskId)
}
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
const taskToRemove = this.tasks.get(taskId)
if (taskToRemove) {
this.clearNotificationsForTask(taskId)
if (taskToRemove.sessionID) {
subagentSessions.delete(taskToRemove.sessionID)
SessionCategoryRegistry.remove(taskToRemove.sessionID)
}
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
}, TASK_CLEANUP_DELAY_MS)
this.completionTimers.set(taskId, timer)
}
if (task.status !== "running" && task.status !== "pending") {
this.scheduleTaskRemoval(task.id)
}
}
@@ -1554,6 +1598,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
}
this.cleanupPendingByParent(task)
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
@@ -1657,7 +1702,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
* Cancels all pending concurrency waiters and clears timers.
* Should be called when the plugin is unloaded.
*/
shutdown(): void {
async shutdown(): Promise<void> {
if (this.shutdownTriggered) return
this.shutdownTriggered = true
log("[background-agent] Shutting down BackgroundManager")
@@ -1675,7 +1720,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
// Notify shutdown listeners (e.g., tmux cleanup)
if (this.onShutdown) {
try {
this.onShutdown()
await this.onShutdown()
} catch (error) {
log("[background-agent] Error in onShutdown callback:", error)
}
@@ -1708,6 +1753,8 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
this.taskHistory.clearAll()
this.completedTaskSummaries.clear()
this.unregisterProcessCleanup()
log("[background-agent] Shutdown complete")
@@ -19,7 +19,7 @@ function registerProcessSignal(
}
interface CleanupTarget {
shutdown(): void
shutdown(): void | Promise<void>
}
const cleanupManagers = new Set<CleanupTarget>()
@@ -35,7 +35,9 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
const cleanupAll = () => {
for (const m of cleanupManagers) {
try {
m.shutdown()
void Promise.resolve(m.shutdown()).catch((error) => {
log("[background-agent] Error during async shutdown cleanup:", error)
})
} catch (error) {
log("[background-agent] Error during shutdown cleanup:", error)
}
@@ -0,0 +1,245 @@
declare const require: (name: string) => any
const { describe, test, expect, afterEach } = require("bun:test")
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
type PromptAsyncCall = {
path: { id: string }
body: {
noReply?: boolean
parts?: unknown[]
}
}
type FakeTimers = {
getDelay: (timer: ReturnType<typeof setTimeout>) => number | undefined
run: (timer: ReturnType<typeof setTimeout>) => void
restore: () => void
}
let managerUnderTest: BackgroundManager | undefined
let fakeTimers: FakeTimers | undefined
afterEach(() => {
managerUnderTest?.shutdown()
fakeTimers?.restore()
managerUnderTest = undefined
fakeTimers = undefined
})
function createTask(overrides: Partial<BackgroundTask> & { id: string; parentSessionID: string }): BackgroundTask {
const id = overrides.id
const parentSessionID = overrides.parentSessionID
const { id: _ignoredID, parentSessionID: _ignoredParentSessionID, ...rest } = overrides
return {
parentMessageID: overrides.parentMessageID ?? "parent-message-id",
description: overrides.description ?? overrides.id,
prompt: overrides.prompt ?? `Prompt for ${overrides.id}`,
agent: overrides.agent ?? "test-agent",
status: overrides.status ?? "running",
startedAt: overrides.startedAt ?? new Date("2026-03-11T00:00:00.000Z"),
...rest,
id,
parentSessionID,
}
}
function createManager(enableParentSessionNotifications: boolean): {
manager: BackgroundManager
promptAsyncCalls: PromptAsyncCall[]
} {
const promptAsyncCalls: PromptAsyncCall[] = []
const client = {
session: {
messages: async () => [],
prompt: async () => ({}),
promptAsync: async (call: PromptAsyncCall) => {
promptAsyncCalls.push(call)
return {}
},
abort: async () => ({}),
},
}
const placeholderClient = {} as PluginInput["client"]
const ctx: PluginInput = {
client: placeholderClient,
project: {} as PluginInput["project"],
directory: tmpdir(),
worktree: tmpdir(),
serverUrl: new URL("http://localhost"),
$: {} as PluginInput["$"],
}
const manager = new BackgroundManager(
ctx,
undefined,
{ enableParentSessionNotifications }
)
Reflect.set(manager, "client", client)
return { manager, promptAsyncCalls }
}
function installFakeTimers(): FakeTimers {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const callbacks = new Map<ReturnType<typeof setTimeout>, () => void>()
const delays = new Map<ReturnType<typeof setTimeout>, number>()
globalThis.setTimeout = ((handler: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]): ReturnType<typeof setTimeout> => {
if (typeof handler !== "function") {
throw new Error("Expected function timeout handler")
}
const timer = originalSetTimeout(() => {}, 60_000)
originalClearTimeout(timer)
const callback = handler as (...callbackArgs: Array<unknown>) => void
callbacks.set(timer, () => callback(...args))
delays.set(timer, delay ?? 0)
return timer
}) as typeof setTimeout
globalThis.clearTimeout = ((timer: ReturnType<typeof setTimeout>): void => {
callbacks.delete(timer)
delays.delete(timer)
}) as typeof clearTimeout
return {
getDelay(timer) {
return delays.get(timer)
},
run(timer) {
const callback = callbacks.get(timer)
if (!callback) {
throw new Error(`Timer not found: ${String(timer)}`)
}
callbacks.delete(timer)
delays.delete(timer)
callback()
},
restore() {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
},
}
}
function getTasks(manager: BackgroundManager): Map<string, BackgroundTask> {
return Reflect.get(manager, "tasks") as Map<string, BackgroundTask>
}
function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>> {
return Reflect.get(manager, "pendingByParent") as Map<string, Set<string>>
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return Reflect.get(manager, "completionTimers") as Map<string, ReturnType<typeof setTimeout>>
}
async function notifyParentSessionForTest(manager: BackgroundManager, task: BackgroundTask): Promise<void> {
const notifyParentSession = Reflect.get(manager, "notifyParentSession") as (task: BackgroundTask) => Promise<void>
return notifyParentSession.call(manager, task)
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
if (timer === undefined) {
throw new Error(`Missing completion timer for ${taskID}`)
}
return timer
}
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
describe("#given 2 tasks for same parent and task A completed", () => {
test("#when task B is still running #then task A is cleaned up from this.tasks after delay even though task B is not done", async () => {
// given
const { manager } = createManager(false)
managerUnderTest = manager
fakeTimers = installFakeTimers()
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
getTasks(manager).set(taskA.id, taskA)
getTasks(manager).set(taskB.id, taskB)
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
// when
await notifyParentSessionForTest(manager, taskA)
const taskATimer = getRequiredTimer(manager, taskA.id)
expect(fakeTimers.getDelay(taskATimer)).toBe(TASK_CLEANUP_DELAY_MS)
fakeTimers.run(taskATimer)
// then
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
expect(getTasks(manager).has(taskA.id)).toBe(false)
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
})
})
describe("#given 2 tasks for same parent and both completed", () => {
test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
managerUnderTest = manager
fakeTimers = installFakeTimers()
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
getTasks(manager).set(taskA.id, taskA)
getTasks(manager).set(taskB.id, taskB)
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
await notifyParentSessionForTest(manager, taskA)
taskB.status = "completed"
taskB.completedAt = new Date("2026-03-11T00:02:00.000Z")
// when
await notifyParentSessionForTest(manager, taskB)
// then
expect(promptAsyncCalls).toHaveLength(2)
expect(getCompletionTimers(manager).size).toBe(2)
const allCompleteCall = promptAsyncCalls[1]
expect(allCompleteCall).toBeDefined()
if (!allCompleteCall) {
throw new Error("Missing all-complete notification call")
}
expect(allCompleteCall.body.noReply).toBe(false)
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts)
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(allCompletePayload).toContain(taskA.id)
expect(allCompletePayload).toContain(taskB.id)
expect(allCompletePayload).toContain(taskA.description)
expect(allCompletePayload).toContain(taskB.description)
})
})
describe("#given a completed task with cleanup timer scheduled", () => {
test("#when cleanup timer fires #then task is deleted from this.tasks Map", async () => {
// given
const { manager } = createManager(false)
managerUnderTest = manager
fakeTimers = installFakeTimers()
const task = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
const cleanupTimer = getRequiredTimer(manager, task.id)
// when
expect(fakeTimers.getDelay(cleanupTimer)).toBe(TASK_CLEANUP_DELAY_MS)
fakeTimers.run(cleanupTimer)
// then
expect(getCompletionTimers(manager).has(task.id)).toBe(false)
expect(getTasks(manager).has(task.id)).toBe(false)
})
})
})
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import { TaskHistory } from "./task-history"
import type { BackgroundTask } from "./types"
let managerUnderTest: BackgroundManager | undefined
afterEach(() => {
managerUnderTest?.shutdown()
managerUnderTest = undefined
})
function createManager(): BackgroundManager {
const client = {
session: {
abort: async () => ({}),
},
}
const placeholderClient = {} as PluginInput["client"]
const ctx: PluginInput = {
client: placeholderClient,
project: {} as PluginInput["project"],
directory: tmpdir(),
worktree: tmpdir(),
serverUrl: new URL("http://localhost"),
$: {} as PluginInput["$"],
}
const manager = new BackgroundManager(ctx)
Reflect.set(manager, "client", client)
return manager
}
function createTask(overrides: Partial<BackgroundTask> & { id: string; parentSessionID: string }): BackgroundTask {
const { id, parentSessionID, ...rest } = overrides
return {
...rest,
id,
parentSessionID,
parentMessageID: rest.parentMessageID ?? "parent-message-id",
description: rest.description ?? id,
prompt: rest.prompt ?? `Prompt for ${id}`,
agent: rest.agent ?? "test-agent",
status: rest.status ?? "running",
startedAt: rest.startedAt ?? new Date("2026-03-11T00:00:00.000Z"),
}
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return Reflect.get(manager, "tasks") as Map<string, BackgroundTask>
}
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
const pruneStaleTasksAndNotifications = Reflect.get(manager, "pruneStaleTasksAndNotifications") as () => void
pruneStaleTasksAndNotifications.call(manager)
}
describe("task history cleanup", () => {
test("#given TaskHistory with entries for multiple parents #when clearSession called for one parent #then only that parent's entries are removed, others remain", () => {
// given
const history = new TaskHistory()
history.record("parent-1", { id: "task-1", agent: "explore", description: "task 1", status: "pending" })
history.record("parent-2", { id: "task-2", agent: "oracle", description: "task 2", status: "running" })
// when
history.clearSession("parent-1")
// then
expect(history.getByParentSession("parent-1")).toHaveLength(0)
expect(history.getByParentSession("parent-2")).toHaveLength(1)
})
test("#given TaskHistory with entries for multiple parents #when clearAll called #then all entries are removed", () => {
// given
const history = new TaskHistory()
history.record("parent-1", { id: "task-1", agent: "explore", description: "task 1", status: "pending" })
history.record("parent-2", { id: "task-2", agent: "oracle", description: "task 2", status: "running" })
// when
history.clearAll()
// then
expect(history.getByParentSession("parent-1")).toHaveLength(0)
expect(history.getByParentSession("parent-2")).toHaveLength(0)
})
test("#given BackgroundManager with taskHistory entries #when shutdown() called #then taskHistory is cleared via clearAll()", () => {
// given
const manager = createManager()
managerUnderTest = manager
manager.taskHistory.record("parent-1", { id: "task-1", agent: "explore", description: "task 1", status: "pending" })
let clearAllCalls = 0
const originalClearAll = manager.taskHistory.clearAll.bind(manager.taskHistory)
manager.taskHistory.clearAll = (): void => {
clearAllCalls += 1
originalClearAll()
}
// when
manager.shutdown()
// then
expect(clearAllCalls).toBe(1)
expect(manager.taskHistory.getByParentSession("parent-1")).toHaveLength(0)
managerUnderTest = undefined
})
test("#given BackgroundManager with stale tasks for one parent #when pruneStaleTasksAndNotifications() runs #then history is preserved until delayed cleanup", () => {
// given
const manager = createManager()
managerUnderTest = manager
const staleTask = createTask({
id: "task-stale",
parentSessionID: "parent-1",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
const liveTask = createTask({
id: "task-live",
parentSessionID: "parent-2",
startedAt: new Date(),
})
getTaskMap(manager).set(staleTask.id, staleTask)
getTaskMap(manager).set(liveTask.id, liveTask)
manager.taskHistory.record("parent-1", { id: staleTask.id, agent: staleTask.agent, description: staleTask.description, status: staleTask.status })
manager.taskHistory.record("parent-2", { id: liveTask.id, agent: liveTask.agent, description: liveTask.description, status: liveTask.status })
// when
pruneStaleTasksAndNotificationsForTest(manager)
// then
expect(manager.taskHistory.getByParentSession("parent-1")).toHaveLength(1)
expect(manager.taskHistory.getByParentSession("parent-2")).toHaveLength(1)
})
})
@@ -54,6 +54,10 @@ export class TaskHistory {
this.entries.delete(parentSessionID)
}
clearAll(): void {
this.entries.clear()
}
formatForCompaction(parentSessionID: string): string | null {
const list = this.getByParentSession(parentSessionID)
if (list.length === 0) return null
+26 -2
View File
@@ -19,11 +19,13 @@ export function registerProcessCleanup(state: SkillMcpManagerState): void {
state.cleanupRegistered = true
const cleanup = async (): Promise<void> => {
state.shutdownGeneration++
for (const managed of state.clients.values()) {
await closeManagedClient(managed)
}
state.clients.clear()
state.pendingConnections.clear()
state.disconnectedSessions.clear()
}
// Note: Node's 'exit' event is synchronous-only, so we rely on signal handlers for async cleanup.
@@ -79,12 +81,23 @@ async function cleanupIdleClients(state: SkillMcpManagerState): Promise<void> {
}
}
if (state.clients.size === 0) {
if (state.clients.size === 0 && state.pendingConnections.size === 0) {
stopCleanupTimer(state)
unregisterProcessCleanup(state)
}
}
export async function disconnectSession(state: SkillMcpManagerState, sessionID: string): Promise<void> {
let hasPendingForSession = false
for (const key of state.pendingConnections.keys()) {
if (key.startsWith(`${sessionID}:`)) {
hasPendingForSession = true
break
}
}
if (hasPendingForSession) {
state.disconnectedSessions.set(sessionID, (state.disconnectedSessions.get(sessionID) ?? 0) + 1)
}
const keysToRemove: string[] = []
for (const [key, managed] of state.clients.entries()) {
@@ -96,22 +109,33 @@ export async function disconnectSession(state: SkillMcpManagerState, sessionID:
}
}
for (const key of state.pendingConnections.keys()) {
if (key.startsWith(`${sessionID}:`)) {
keysToRemove.push(key)
}
}
for (const key of keysToRemove) {
state.pendingConnections.delete(key)
}
if (state.clients.size === 0) {
if (state.clients.size === 0 && state.pendingConnections.size === 0) {
stopCleanupTimer(state)
unregisterProcessCleanup(state)
}
}
export async function disconnectAll(state: SkillMcpManagerState): Promise<void> {
state.shutdownGeneration++
state.disposed = true
stopCleanupTimer(state)
unregisterProcessCleanup(state)
const clients = Array.from(state.clients.values())
state.clients.clear()
state.pendingConnections.clear()
state.disconnectedSessions.clear()
state.inFlightConnections.clear()
state.authProviders.clear()
for (const managed of clients) {
@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types"
type Deferred<TValue> = {
promise: Promise<TValue>
resolve: (value: TValue) => void
reject: (error: Error) => void
}
const pendingConnects: Deferred<void>[] = []
const trackedStates: SkillMcpManagerState[] = []
const createdClients: MockClient[] = []
const createdTransports: MockStdioClientTransport[] = []
class MockClient {
readonly close = mock(async () => {})
constructor(
_clientInfo: { name: string; version: string },
_options: { capabilities: Record<string, never> }
) {
createdClients.push(this)
}
async connect(_transport: MockStdioClientTransport): Promise<void> {
const pendingConnect = pendingConnects.shift()
if (pendingConnect) {
await pendingConnect.promise
}
}
}
class MockStdioClientTransport {
readonly close = mock(async () => {})
constructor(_options: { command: string; args?: string[]; env?: Record<string, string>; stderr?: string }) {
createdTransports.push(this)
}
}
mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: MockClient,
}))
mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: MockStdioClientTransport,
}))
const { disconnectAll, disconnectSession } = await import("./cleanup")
const { getOrCreateClient } = await import("./connection")
function createDeferred<TValue>(): Deferred<TValue> {
let resolvePromise: ((value: TValue) => void) | null = null
let rejectPromise: ((error: Error) => void) | null = null
const promise = new Promise<TValue>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
if (!resolvePromise || !rejectPromise) {
throw new Error("Failed to create deferred promise")
}
return {
promise,
resolve: resolvePromise,
reject: rejectPromise,
}
}
function createState(): SkillMcpManagerState {
const state: SkillMcpManagerState = {
clients: new Map(),
pendingConnections: new Map(),
disconnectedSessions: new Map(),
authProviders: new Map(),
cleanupRegistered: false,
cleanupInterval: null,
cleanupHandlers: [],
idleTimeoutMs: 5 * 60 * 1000,
shutdownGeneration: 0,
inFlightConnections: new Map(),
disposed: false,
}
trackedStates.push(state)
return state
}
function createClientInfo(sessionID: string): SkillMcpClientInfo {
return {
serverName: "race-server",
skillName: "race-skill",
sessionID,
}
}
function createClientKey(info: SkillMcpClientInfo): string {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
const stdioConfig: ClaudeCodeMcpServer = {
command: "mock-mcp-server",
}
beforeEach(() => {
pendingConnects.length = 0
createdClients.length = 0
createdTransports.length = 0
})
afterEach(async () => {
for (const state of trackedStates) {
await disconnectAll(state)
}
trackedStates.length = 0
pendingConnects.length = 0
createdClients.length = 0
createdTransports.length = 0
})
describe("getOrCreateClient disconnect race", () => {
it("#given pending connection for session A #when disconnectSession(A) is called before connection completes #then completed client is not added to state.clients", async () => {
const state = createState()
const info = createClientInfo("session-a")
const clientKey = createClientKey(info)
const pendingConnect = createDeferred<void>()
pendingConnects.push(pendingConnect)
const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig })
expect(state.pendingConnections.has(clientKey)).toBe(true)
await disconnectSession(state, info.sessionID)
pendingConnect.resolve(undefined)
await expect(clientPromise).rejects.toThrow(/disconnected during MCP connection setup/)
expect(state.clients.has(clientKey)).toBe(false)
expect(state.pendingConnections.has(clientKey)).toBe(false)
expect(state.disconnectedSessions.has(info.sessionID)).toBe(false)
expect(createdClients).toHaveLength(1)
expect(createdClients[0]?.close).toHaveBeenCalledTimes(1)
expect(createdTransports[0]?.close).toHaveBeenCalledTimes(1)
})
it("#given session A in disconnectedSessions #when new connection completes with no remaining pending #then disconnectedSessions entry is cleaned up", async () => {
const state = createState()
const info = createClientInfo("session-a")
const clientKey = createClientKey(info)
state.disconnectedSessions.set(info.sessionID, 1)
const client = await getOrCreateClient({ state, clientKey, info, config: stdioConfig })
expect(state.disconnectedSessions.has(info.sessionID)).toBe(false)
expect(state.clients.get(clientKey)?.client).toBe(client)
expect(createdClients[0]?.close).not.toHaveBeenCalled()
})
it("#given no pending connections #when disconnectSession is called #then no errors occur and session is not added to disconnectedSessions", async () => {
const state = createState()
await expect(disconnectSession(state, "session-a")).resolves.toBeUndefined()
expect(state.disconnectedSessions.has("session-a")).toBe(false)
expect(state.pendingConnections.size).toBe(0)
expect(state.clients.size).toBe(0)
})
})
describe("getOrCreateClient disconnectAll race", () => {
it("#given pending connection #when disconnectAll() is called before connection completes #then client is not added to state.clients", async () => {
const state = createState()
const info = createClientInfo("session-a")
const clientKey = createClientKey(info)
const pendingConnect = createDeferred<void>()
pendingConnects.push(pendingConnect)
const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig })
expect(state.pendingConnections.has(clientKey)).toBe(true)
await disconnectAll(state)
pendingConnect.resolve(undefined)
await expect(clientPromise).rejects.toThrow(/connection completed after shutdown/)
expect(state.clients.has(clientKey)).toBe(false)
})
it("#given state after disconnectAll() completed #when getOrCreateClient() is called #then it throws shut down error and registers nothing", async () => {
const state = createState()
const info = createClientInfo("session-b")
const clientKey = createClientKey(info)
await disconnectAll(state)
await expect(getOrCreateClient({ state, clientKey, info, config: stdioConfig })).rejects.toThrow(/has been shut down/)
expect(state.clients.size).toBe(0)
expect(state.pendingConnections.size).toBe(0)
expect(state.inFlightConnections.size).toBe(0)
expect(state.disposed).toBe(true)
expect(createdClients).toHaveLength(0)
expect(createdTransports).toHaveLength(0)
})
})
describe("getOrCreateClient multi-key disconnect race", () => {
it("#given 2 pending connections for session A #when disconnectSession(A) before both complete #then both old connections are rejected", async () => {
const state = createState()
const infoKey1 = createClientInfo("session-a")
const infoKey2 = { ...createClientInfo("session-a"), serverName: "server-2" }
const clientKey1 = createClientKey(infoKey1)
const clientKey2 = `${infoKey2.sessionID}:${infoKey2.skillName}:${infoKey2.serverName}`
const pendingConnect1 = createDeferred<void>()
const pendingConnect2 = createDeferred<void>()
pendingConnects.push(pendingConnect1)
pendingConnects.push(pendingConnect2)
const promise1 = getOrCreateClient({ state, clientKey: clientKey1, info: infoKey1, config: stdioConfig })
const promise2 = getOrCreateClient({ state, clientKey: clientKey2, info: infoKey2, config: stdioConfig })
expect(state.pendingConnections.size).toBe(2)
await disconnectSession(state, "session-a")
pendingConnect1.resolve(undefined)
await expect(promise1).rejects.toThrow(/disconnected during MCP connection setup/)
pendingConnect2.resolve(undefined)
await expect(promise2).rejects.toThrow(/disconnected during MCP connection setup/)
expect(state.clients.has(clientKey1)).toBe(false)
expect(state.clients.has(clientKey2)).toBe(false)
expect(state.disconnectedSessions.has("session-a")).toBe(false)
})
it("#given a superseded pending connection #when the old connection completes #then the stale client is removed from state.clients", async () => {
const state = createState()
const info = createClientInfo("session-a")
const clientKey = createClientKey(info)
const pendingConnect = createDeferred<void>()
const supersedingConnection = createDeferred<Awaited<ReturnType<typeof getOrCreateClient>>>()
pendingConnects.push(pendingConnect)
const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig })
state.pendingConnections.set(clientKey, supersedingConnection.promise)
pendingConnect.resolve(undefined)
await expect(clientPromise).rejects.toThrow(/superseded by a newer connection attempt/)
expect(state.clients.has(clientKey)).toBe(false)
expect(createdClients[0]?.close).toHaveBeenCalledTimes(1)
})
it("#given a superseded pending connection #when a newer client already replaced the map entry #then the stale cleanup does not delete the newer client", async () => {
const state = createState()
const info = createClientInfo("session-a")
const clientKey = createClientKey(info)
const pendingConnect = createDeferred<void>()
const supersedingConnection = createDeferred<Awaited<ReturnType<typeof getOrCreateClient>>>()
pendingConnects.push(pendingConnect)
const newerClient = new MockClient(
{ name: "newer-client", version: "1.0.0" },
{ capabilities: {} },
)
const newerTransport = new MockStdioClientTransport({ command: "mock-mcp-server" })
let replacedEntry = false
const originalSet = state.clients.set.bind(state.clients)
Reflect.set(state.clients, "set", (key: string, value: SkillMcpManagerState["clients"] extends Map<string, infer TValue> ? TValue : never) => {
originalSet(key, value)
if (!replacedEntry && key === clientKey) {
replacedEntry = true
originalSet(key, {
client: newerClient as never,
transport: newerTransport as never,
skillName: info.skillName,
lastUsedAt: Date.now(),
connectionType: "stdio",
})
}
return state.clients
})
const clientPromise = getOrCreateClient({ state, clientKey, info, config: stdioConfig })
state.pendingConnections.set(clientKey, supersedingConnection.promise)
pendingConnect.resolve(undefined)
await expect(clientPromise).rejects.toThrow(/superseded by a newer connection attempt/)
expect(state.clients.get(clientKey)?.client.close).toBe(newerClient.close)
expect(newerClient.close).not.toHaveBeenCalled()
})
})
+53 -4
View File
@@ -7,6 +7,13 @@ import { createHttpClient } from "./http-client"
import { createStdioClient } from "./stdio-client"
import type { SkillMcpClientConnectionParams, SkillMcpClientInfo, SkillMcpManagerState } from "./types"
function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, client: Client): void {
const managed = state.clients.get(clientKey)
if (managed?.client === client) {
state.clients.delete(clientKey)
}
}
export async function getOrCreateClient(params: {
state: SkillMcpManagerState
clientKey: string
@@ -15,6 +22,10 @@ export async function getOrCreateClient(params: {
}): Promise<Client> {
const { state, clientKey, info, config } = params
if (state.disposed) {
throw new Error(`MCP manager for "${info.sessionID}" has been shut down, cannot create new connections.`)
}
const existing = state.clients.get(clientKey)
if (existing) {
existing.lastUsedAt = Date.now()
@@ -28,14 +39,52 @@ export async function getOrCreateClient(params: {
}
const expandedConfig = expandEnvVarsInObject(config)
const connectionPromise = createClient({ state, clientKey, info, config: expandedConfig })
state.pendingConnections.set(clientKey, connectionPromise)
let currentConnectionPromise!: Promise<Client>
state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1)
currentConnectionPromise = (async () => {
const disconnectGenAtStart = state.disconnectedSessions.get(info.sessionID) ?? 0
const shutdownGenAtStart = state.shutdownGeneration
const client = await createClient({ state, clientKey, info, config: expandedConfig })
const isStale = state.pendingConnections.has(clientKey) && state.pendingConnections.get(clientKey) !== currentConnectionPromise
if (isStale) {
removeClientIfCurrent(state, clientKey, client)
try { await client.close() } catch {}
throw new Error(`Connection for "${info.sessionID}" was superseded by a newer connection attempt.`)
}
if (state.shutdownGeneration !== shutdownGenAtStart) {
removeClientIfCurrent(state, clientKey, client)
try { await client.close() } catch {}
throw new Error(`Shutdown occurred during MCP connection for "${info.sessionID}"`)
}
const currentDisconnectGen = state.disconnectedSessions.get(info.sessionID) ?? 0
if (currentDisconnectGen > disconnectGenAtStart) {
await forceReconnect(state, clientKey)
throw new Error(`Session "${info.sessionID}" disconnected during MCP connection setup.`)
}
return client
})()
state.pendingConnections.set(clientKey, currentConnectionPromise)
try {
const client = await connectionPromise
const client = await currentConnectionPromise
return client
} finally {
state.pendingConnections.delete(clientKey)
if (state.pendingConnections.get(clientKey) === currentConnectionPromise) {
state.pendingConnections.delete(clientKey)
}
const remaining = (state.inFlightConnections.get(info.sessionID) ?? 1) - 1
if (remaining <= 0) {
state.inFlightConnections.delete(info.sessionID)
state.disconnectedSessions.delete(info.sessionID)
} else {
state.inFlightConnections.set(info.sessionID, remaining)
}
}
}
@@ -0,0 +1,133 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { afterEach, describe, expect, it } from "bun:test"
import { disconnectSession, registerProcessCleanup, unregisterProcessCleanup } from "./cleanup"
import type { ManagedClient, SkillMcpManagerState } from "./types"
const trackedStates: SkillMcpManagerState[] = []
afterEach(() => {
for (const state of trackedStates) {
unregisterProcessCleanup(state)
}
trackedStates.length = 0
})
const expectedCleanupHandlerCount = process.platform === "win32" ? 3 : 2
function createState(): SkillMcpManagerState {
const state: SkillMcpManagerState = {
clients: new Map(),
pendingConnections: new Map(),
disconnectedSessions: new Map(),
authProviders: new Map(),
cleanupRegistered: false,
cleanupInterval: null,
cleanupHandlers: [],
idleTimeoutMs: 5 * 60 * 1000,
shutdownGeneration: 0,
inFlightConnections: new Map(),
disposed: false,
}
trackedStates.push(state)
return state
}
function createManagedClient(skillName: string): ManagedClient {
return {
client: new Client(
{ name: `test-${skillName}`, version: "1.0.0" },
{ capabilities: {} }
),
transport: new StreamableHTTPClientTransport(new URL("https://example.com/mcp")),
skillName,
lastUsedAt: Date.now(),
connectionType: "http",
}
}
describe("disconnectSession cleanup registration", () => {
it("#given state with 1 client and cleanup registered #when disconnectSession removes last client #then process cleanup handlers are unregistered", async () => {
// given
const state = createState()
const signalIntCountBeforeRegister = process.listenerCount("SIGINT")
const signalTermCountBeforeRegister = process.listenerCount("SIGTERM")
state.clients.set("session-1:skill-1:server-1", createManagedClient("skill-1"))
registerProcessCleanup(state)
// when
await disconnectSession(state, "session-1")
// then
expect(state.cleanupRegistered).toBe(false)
expect(state.cleanupHandlers).toEqual([])
expect(process.listenerCount("SIGINT")).toBe(signalIntCountBeforeRegister)
expect(process.listenerCount("SIGTERM")).toBe(signalTermCountBeforeRegister)
})
it("#given state with 2 clients in different sessions #when disconnectSession removes one session #then process cleanup handlers remain registered", async () => {
// given
const state = createState()
const signalIntCountBeforeRegister = process.listenerCount("SIGINT")
const signalTermCountBeforeRegister = process.listenerCount("SIGTERM")
state.clients.set("session-1:skill-1:server-1", createManagedClient("skill-1"))
state.clients.set("session-2:skill-2:server-2", createManagedClient("skill-2"))
registerProcessCleanup(state)
// when
await disconnectSession(state, "session-1")
// then
expect(state.clients.has("session-2:skill-2:server-2")).toBe(true)
expect(state.cleanupRegistered).toBe(true)
expect(state.cleanupHandlers).toHaveLength(expectedCleanupHandlerCount)
expect(process.listenerCount("SIGINT")).toBe(signalIntCountBeforeRegister + 1)
expect(process.listenerCount("SIGTERM")).toBe(signalTermCountBeforeRegister + 1)
})
it("#given state with 2 clients in different sessions #when both sessions disconnected #then process cleanup handlers are unregistered", async () => {
// given
const state = createState()
const signalIntCountBeforeRegister = process.listenerCount("SIGINT")
const signalTermCountBeforeRegister = process.listenerCount("SIGTERM")
state.clients.set("session-1:skill-1:server-1", createManagedClient("skill-1"))
state.clients.set("session-2:skill-2:server-2", createManagedClient("skill-2"))
registerProcessCleanup(state)
// when
await disconnectSession(state, "session-1")
await disconnectSession(state, "session-2")
// then
expect(state.clients.size).toBe(0)
expect(state.cleanupRegistered).toBe(false)
expect(state.cleanupHandlers).toEqual([])
expect(process.listenerCount("SIGINT")).toBe(signalIntCountBeforeRegister)
expect(process.listenerCount("SIGTERM")).toBe(signalTermCountBeforeRegister)
})
it("#given state with 1 client and pending connection for different session and cleanup registered #when disconnectSession removes last client but pendingConnections remain #then process cleanup handlers stay registered", async () => {
const state = createState()
const signalIntCountBeforeRegister = process.listenerCount("SIGINT")
const signalTermCountBeforeRegister = process.listenerCount("SIGTERM")
const pendingClient = createManagedClient("skill-pending").client
state.clients.set("session-1:skill-1:server-1", createManagedClient("skill-1"))
state.pendingConnections.set("session-2:skill-2:server-2", Promise.resolve(pendingClient))
registerProcessCleanup(state)
await disconnectSession(state, "session-1")
expect(state.clients.size).toBe(0)
expect(state.pendingConnections.size).toBe(1)
expect(state.cleanupRegistered).toBe(true)
expect(state.cleanupHandlers).toHaveLength(expectedCleanupHandlerCount)
expect(process.listenerCount("SIGINT")).toBe(signalIntCountBeforeRegister + 1)
expect(process.listenerCount("SIGTERM")).toBe(signalTermCountBeforeRegister + 1)
})
})
@@ -24,6 +24,7 @@ function redactUrl(urlStr: string): string {
export async function createHttpClient(params: SkillMcpClientConnectionParams): Promise<Client> {
const { state, clientKey, info, config } = params
const shutdownGenAtStart = state.shutdownGeneration
if (!config.url) {
throw new Error(`MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.`)
@@ -72,6 +73,12 @@ export async function createHttpClient(params: SkillMcpClientConnectionParams):
)
}
if (state.shutdownGeneration !== shutdownGenAtStart) {
try { await client.close() } catch {}
try { await transport.close() } catch {}
throw new Error(`MCP server "${info.serverName}" connection completed after shutdown`)
}
const managedClient = {
client,
transport,
@@ -10,11 +10,15 @@ export class SkillMcpManager {
private readonly state: SkillMcpManagerState = {
clients: new Map(),
pendingConnections: new Map(),
disconnectedSessions: new Map(),
authProviders: new Map(),
cleanupRegistered: false,
cleanupInterval: null,
cleanupHandlers: [],
idleTimeoutMs: 5 * 60 * 1000,
shutdownGeneration: 0,
inFlightConnections: new Map(),
disposed: false,
}
private getClientKey(info: SkillMcpClientInfo): string {
@@ -14,6 +14,7 @@ function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): strin
export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise<Client> {
const { state, clientKey, info, config } = params
const shutdownGenAtStart = state.shutdownGeneration
const command = getStdioCommand(config, info.serverName)
const args = config.args ?? []
@@ -55,6 +56,12 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams):
)
}
if (state.shutdownGeneration !== shutdownGenAtStart) {
try { await client.close() } catch {}
try { await transport.close() } catch {}
throw new Error(`MCP server "${info.serverName}" connection completed after shutdown`)
}
const managedClient = {
client,
transport,
+4
View File
@@ -51,11 +51,15 @@ export interface ProcessCleanupHandler {
export interface SkillMcpManagerState {
clients: Map<string, ManagedClient>
pendingConnections: Map<string, Promise<Client>>
disconnectedSessions: Map<string, number>
authProviders: Map<string, McpOAuthProvider>
cleanupRegistered: boolean
cleanupInterval: ReturnType<typeof setInterval> | null
cleanupHandlers: ProcessCleanupHandler[]
idleTimeoutMs: number
shutdownGeneration: number
inFlightConnections: Map<string, number>
disposed: boolean
}
export interface SkillMcpClientConnectionParams {
+191 -66
View File
@@ -1,6 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types"
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
import { log, normalizeSDKResponse } from "../../shared"
import {
isInsideTmux as defaultIsInsideTmux,
@@ -13,6 +13,7 @@ import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
import { executeActions, executeAction } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
import { createTrackedSession, markTrackedSessionClosePending } from "./tracked-session-state"
type OpencodeClient = PluginInput["client"]
interface SessionCreatedEvent {
@@ -38,6 +39,7 @@ const defaultTmuxDeps: TmuxUtilDeps = {
const DEFERRED_SESSION_TTL_MS = 5 * 60 * 1000
const MAX_DEFERRED_QUEUE_SIZE = 20
const MAX_CLOSE_RETRY_COUNT = 3
/**
* State-first Tmux Session Manager
@@ -106,6 +108,123 @@ export class TmuxSessionManager {
}))
}
private removeTrackedSession(sessionId: string): void {
this.sessions.delete(sessionId)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
}
private markSessionClosePending(sessionId: string): void {
const tracked = this.sessions.get(sessionId)
if (!tracked) return
this.sessions.set(sessionId, markTrackedSessionClosePending(tracked))
log("[tmux-session-manager] marked session close pending", {
sessionId,
paneId: tracked.paneId,
closeRetryCount: tracked.closeRetryCount,
})
}
private async queryWindowStateSafely(): Promise<WindowState | null> {
if (!this.sourcePaneId) return null
try {
return await queryWindowState(this.sourcePaneId)
} catch (error) {
log("[tmux-session-manager] failed to query window state for close", {
error: String(error),
})
return null
}
}
private async tryCloseTrackedSession(tracked: TrackedSession): Promise<boolean> {
const state = await this.queryWindowStateSafely()
if (!state) return false
try {
const result = await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
}
)
return result.success
} catch (error) {
log("[tmux-session-manager] close session pane failed", {
sessionId: tracked.sessionId,
paneId: tracked.paneId,
error: String(error),
})
return false
}
}
private async retryPendingCloses(): Promise<void> {
const pendingSessions = Array.from(this.sessions.values()).filter(
(tracked) => tracked.closePending,
)
for (const tracked of pendingSessions) {
if (!this.sessions.has(tracked.sessionId)) continue
if (tracked.closeRetryCount >= MAX_CLOSE_RETRY_COUNT) {
log("[tmux-session-manager] force removing close-pending session after max retries", {
sessionId: tracked.sessionId,
paneId: tracked.paneId,
closeRetryCount: tracked.closeRetryCount,
})
this.removeTrackedSession(tracked.sessionId)
continue
}
const closed = await this.tryCloseTrackedSession(tracked)
if (closed) {
log("[tmux-session-manager] retried close succeeded", {
sessionId: tracked.sessionId,
paneId: tracked.paneId,
closeRetryCount: tracked.closeRetryCount,
})
this.removeTrackedSession(tracked.sessionId)
continue
}
const currentTracked = this.sessions.get(tracked.sessionId)
if (!currentTracked || !currentTracked.closePending) {
continue
}
const nextRetryCount = currentTracked.closeRetryCount + 1
if (nextRetryCount >= MAX_CLOSE_RETRY_COUNT) {
log("[tmux-session-manager] force removing close-pending session after failed retry", {
sessionId: currentTracked.sessionId,
paneId: currentTracked.paneId,
closeRetryCount: nextRetryCount,
})
this.removeTrackedSession(currentTracked.sessionId)
continue
}
this.sessions.set(currentTracked.sessionId, {
...currentTracked,
closePending: true,
closeRetryCount: nextRetryCount,
})
log("[tmux-session-manager] retried close failed", {
sessionId: currentTracked.sessionId,
paneId: currentTracked.paneId,
closeRetryCount: nextRetryCount,
})
}
}
private enqueueDeferredSession(sessionId: string, title: string): void {
if (this.deferredSessions.has(sessionId)) return
if (this.deferredQueue.length >= MAX_DEFERRED_QUEUE_SIZE) {
@@ -257,14 +376,14 @@ export class TmuxSessionManager {
})
}
const now = Date.now()
this.sessions.set(sessionId, {
this.sessions.set(
sessionId,
paneId: result.spawnedPaneId,
description: deferred.title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
createTrackedSession({
sessionId,
paneId: result.spawnedPaneId,
description: deferred.title,
}),
)
this.removeDeferredSession(sessionId)
this.pollingManager.startPolling()
log("[tmux-session-manager] deferred session attached", {
@@ -324,6 +443,13 @@ export class TmuxSessionManager {
const sessionId = info.id
const title = info.title ?? "Subagent"
if (!this.sourcePaneId) {
log("[tmux-session-manager] no source pane id")
return
}
await this.retryPendingCloses()
if (
this.sessions.has(sessionId) ||
this.pendingSessions.has(sessionId) ||
@@ -332,11 +458,6 @@ export class TmuxSessionManager {
log("[tmux-session-manager] session already tracked or pending", { sessionId })
return
}
if (!this.sourcePaneId) {
log("[tmux-session-manager] no source pane id")
return
}
const sourcePaneId = this.sourcePaneId
this.pendingSessions.add(sessionId)
@@ -418,14 +539,14 @@ export class TmuxSessionManager {
})
}
const now = Date.now()
this.sessions.set(sessionId, {
this.sessions.set(
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
createTrackedSession({
sessionId,
paneId: result.spawnedPaneId,
description: title,
}),
)
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
@@ -485,27 +606,40 @@ export class TmuxSessionManager {
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
const state = await queryWindowState(this.sourcePaneId)
const state = await this.queryWindowStateSafely()
if (!state) {
this.sessions.delete(event.sessionID)
this.markSessionClosePending(event.sessionID)
return
}
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
if (closeAction) {
await executeAction(closeAction, {
if (!closeAction) {
this.removeTrackedSession(event.sessionID)
return
}
try {
const result = await executeAction(closeAction, {
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
})
if (!result.success) {
this.markSessionClosePending(event.sessionID)
return
}
} catch (error) {
log("[tmux-session-manager] failed to close pane for deleted session", {
sessionId: event.sessionID,
error: String(error),
})
this.markSessionClosePending(event.sessionID)
return
}
this.sessions.delete(event.sessionID)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
this.removeTrackedSession(event.sessionID)
}
@@ -513,29 +647,28 @@ export class TmuxSessionManager {
const tracked = this.sessions.get(sessionId)
if (!tracked) return
if (tracked.closePending && tracked.closeRetryCount >= MAX_CLOSE_RETRY_COUNT) {
log("[tmux-session-manager] force removing close-pending session after max retries", {
sessionId,
paneId: tracked.paneId,
closeRetryCount: tracked.closeRetryCount,
})
this.removeTrackedSession(sessionId)
return
}
log("[tmux-session-manager] closing session pane", {
sessionId,
paneId: tracked.paneId,
})
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId },
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
}
)
const closed = await this.tryCloseTrackedSession(tracked)
if (!closed) {
this.markSessionClosePending(sessionId)
return
}
this.sessions.delete(sessionId)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
this.removeTrackedSession(sessionId)
}
createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
@@ -552,30 +685,22 @@ export class TmuxSessionManager {
if (this.sessions.size > 0) {
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
const closePromises = Array.from(this.sessions.values()).map((s) =>
executeAction(
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
}
).catch((err) =>
log("[tmux-session-manager] cleanup error for pane", {
paneId: s.paneId,
error: String(err),
}),
),
)
await Promise.all(closePromises)
const sessionIds = Array.from(this.sessions.keys())
for (const sessionId of sessionIds) {
try {
await this.closeSessionById(sessionId)
} catch (error) {
log("[tmux-session-manager] cleanup error for pane", {
sessionId,
error: String(error),
})
}
}
this.sessions.clear()
}
await this.retryPendingCloses()
log("[tmux-session-manager] cleanup complete")
}
}
@@ -12,6 +12,8 @@ describe("TmuxPollingManager overlap", () => {
description: "test",
createdAt: new Date(),
lastSeenAt: new Date(),
closePending: false,
closeRetryCount: 0,
})
let activeCalls = 0
@@ -6,6 +6,7 @@ import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor"
import type { SessionCreatedEvent } from "./session-created-event"
import { createTrackedSession } from "./tracked-session-state"
type OpencodeClient = PluginInput["client"]
@@ -152,14 +153,14 @@ export async function handleSessionCreated(
return
}
const now = Date.now()
deps.sessions.set(sessionId, {
deps.sessions.set(
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
createTrackedSession({
sessionId,
paneId: result.spawnedPaneId,
description: title,
}),
)
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
@@ -0,0 +1,28 @@
import type { TrackedSession } from "./types"
export function createTrackedSession(params: {
sessionId: string
paneId: string
description: string
now?: Date
}): TrackedSession {
const now = params.now ?? new Date()
return {
sessionId: params.sessionId,
paneId: params.paneId,
description: params.description,
createdAt: now,
lastSeenAt: now,
closePending: false,
closeRetryCount: 0,
}
}
export function markTrackedSessionClosePending(tracked: TrackedSession): TrackedSession {
return {
...tracked,
closePending: true,
closeRetryCount: tracked.closePending ? tracked.closeRetryCount + 1 : tracked.closeRetryCount,
}
}
+2
View File
@@ -4,6 +4,8 @@ export interface TrackedSession {
description: string
createdAt: Date
lastSeenAt: Date
closePending: boolean
closeRetryCount: number
// Stability detection fields (prevents premature closure)
lastMessageCount?: number
stableIdlePolls?: number
@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { TmuxConfig } from "../../config/schema"
import type { ActionResult, ExecuteContext, ExecuteActionsResult } from "./action-executor"
import type { TmuxUtilDeps } from "./manager"
import type { TrackedSession, WindowState } from "./types"
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(async () => ({
windowWidth: 220,
windowHeight: 44,
mainPane: { paneId: "%0", width: 110, height: 44, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}))
const mockExecuteAction = mock<(
action: { type: string },
ctx: ExecuteContext,
) => Promise<ActionResult>>(async () => ({ success: true }))
const mockExecuteActions = mock<(
actions: unknown[],
ctx: ExecuteContext,
) => Promise<ExecuteActionsResult>>(async () => ({
success: true,
spawnedPaneId: "%1",
results: [],
}))
const mockIsInsideTmux = mock<() => boolean>(() => true)
const mockGetCurrentPaneId = mock<() => string | undefined>(() => "%0")
mock.module("./pane-state-querier", () => ({
queryWindowState: mockQueryWindowState,
}))
mock.module("./action-executor", () => ({
executeAction: mockExecuteAction,
executeActions: mockExecuteActions,
}))
mock.module("../../shared/tmux", () => ({
isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS: 10,
SESSION_READY_POLL_INTERVAL_MS: 10,
SESSION_READY_TIMEOUT_MS: 50,
SESSION_MISSING_GRACE_MS: 1_000,
}))
const mockTmuxDeps: TmuxUtilDeps = {
isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId,
}
function createConfig(): TmuxConfig {
return {
enabled: true,
layout: "main-vertical",
main_pane_size: 60,
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
}
function createContext() {
const shell = Object.assign(
() => {
throw new Error("shell should not be called in this test")
},
{
braces: () => [],
escape: (input: string) => input,
env() {
return shell
},
cwd() {
return shell
},
nothrow() {
return shell
},
throws() {
return shell
},
},
)
return {
project: {
id: "project-id",
worktree: "/tmp/omo-fix-memory-leaks",
time: { created: Date.now() },
},
directory: "/tmp/omo-fix-memory-leaks",
worktree: "/tmp/omo-fix-memory-leaks",
serverUrl: new URL("http://localhost:4096"),
$: shell,
client: {
session: {
status: mock(async () => ({ data: {} })),
messages: mock(async () => ({ data: [] })),
},
},
}
}
function createTrackedSession(overrides?: Partial<TrackedSession>): TrackedSession {
return {
sessionId: "ses_pending",
paneId: "%1",
description: "Pending pane",
createdAt: new Date(),
lastSeenAt: new Date(),
closePending: false,
closeRetryCount: 0,
...overrides,
}
}
function getTrackedSessions(target: object): Map<string, TrackedSession> {
const sessions = Reflect.get(target, "sessions")
if (!(sessions instanceof Map)) {
throw new Error("Expected sessions map")
}
return sessions
}
function getRetryPendingCloses(target: object): () => Promise<void> {
const retryPendingCloses = Reflect.get(target, "retryPendingCloses")
if (typeof retryPendingCloses !== "function") {
throw new Error("Expected retryPendingCloses method")
}
return retryPendingCloses.bind(target)
}
function getCloseSessionById(target: object): (sessionId: string) => Promise<void> {
const closeSessionById = Reflect.get(target, "closeSessionById")
if (typeof closeSessionById !== "function") {
throw new Error("Expected closeSessionById method")
}
return closeSessionById.bind(target)
}
function createManager(
TmuxSessionManager: typeof import("./manager").TmuxSessionManager,
): import("./manager").TmuxSessionManager {
return Reflect.construct(TmuxSessionManager, [createContext(), createConfig(), mockTmuxDeps])
}
describe("TmuxSessionManager zombie pane handling", () => {
beforeEach(() => {
mockQueryWindowState.mockClear()
mockExecuteAction.mockClear()
mockExecuteActions.mockClear()
mockIsInsideTmux.mockClear()
mockGetCurrentPaneId.mockClear()
mockQueryWindowState.mockImplementation(async () => ({
windowWidth: 220,
windowHeight: 44,
mainPane: { paneId: "%0", width: 110, height: 44, left: 0, top: 0, title: "main", isActive: true },
agentPanes: [],
}))
mockExecuteAction.mockImplementation(async () => ({ success: true }))
mockExecuteActions.mockImplementation(async () => ({
success: true,
spawnedPaneId: "%1",
results: [],
}))
mockIsInsideTmux.mockReturnValue(true)
mockGetCurrentPaneId.mockReturnValue("%0")
})
test("#given session in sessions Map #when onSessionDeleted called with null window state #then session stays in Map with closePending true", async () => {
// given
mockQueryWindowState.mockImplementation(async () => null)
const { TmuxSessionManager } = await import("./manager")
const manager = createManager(TmuxSessionManager)
const sessions = getTrackedSessions(manager)
sessions.set("ses_pending", createTrackedSession())
// when
await manager.onSessionDeleted({ sessionID: "ses_pending" })
// then
const tracked = sessions.get("ses_pending")
expect(tracked).toBeDefined()
expect(tracked?.closePending).toBe(true)
expect(tracked?.closeRetryCount).toBe(0)
expect(mockExecuteAction).not.toHaveBeenCalled()
})
test("#given session with closePending true #when retryPendingCloses succeeds #then session is removed from Map", async () => {
// given
const { TmuxSessionManager } = await import("./manager")
const manager = createManager(TmuxSessionManager)
const sessions = getTrackedSessions(manager)
sessions.set(
"ses_pending",
createTrackedSession({ closePending: true, closeRetryCount: 0 }),
)
// when
await getRetryPendingCloses(manager)()
// then
expect(sessions.has("ses_pending")).toBe(false)
expect(mockExecuteAction).toHaveBeenCalledTimes(1)
})
test("#given session with closePending true and closeRetryCount >= 3 #when retryPendingCloses called #then session is force-removed from Map", async () => {
// given
const { TmuxSessionManager } = await import("./manager")
const manager = createManager(TmuxSessionManager)
const sessions = getTrackedSessions(manager)
sessions.set(
"ses_pending",
createTrackedSession({ closePending: true, closeRetryCount: 3 }),
)
// when
await getRetryPendingCloses(manager)()
// then
expect(sessions.has("ses_pending")).toBe(false)
expect(mockQueryWindowState).not.toHaveBeenCalled()
expect(mockExecuteAction).not.toHaveBeenCalled()
})
test("#given session with closePending true and closeRetryCount >= 3 #when closeSessionById called #then session is force-removed without retrying close", async () => {
// given
const { TmuxSessionManager } = await import("./manager")
const manager = createManager(TmuxSessionManager)
const sessions = getTrackedSessions(manager)
sessions.set(
"ses_pending",
createTrackedSession({ closePending: true, closeRetryCount: 3 }),
)
// when
await getCloseSessionById(manager)("ses_pending")
// then
expect(sessions.has("ses_pending")).toBe(false)
expect(mockQueryWindowState).not.toHaveBeenCalled()
expect(mockExecuteAction).not.toHaveBeenCalled()
})
test("#given close-pending session removed during async close #when retryPendingCloses fails #then it does not resurrect stale session state", async () => {
// given
const { TmuxSessionManager } = await import("./manager")
const manager = createManager(TmuxSessionManager)
const sessions = getTrackedSessions(manager)
sessions.set(
"ses_pending",
createTrackedSession({ closePending: true, closeRetryCount: 0 }),
)
mockExecuteAction.mockImplementationOnce(async () => {
sessions.delete("ses_pending")
return { success: false }
})
// when
await getRetryPendingCloses(manager)()
// then
expect(sessions.has("ses_pending")).toBe(false)
})
})
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { AUTO_SLASH_COMMAND_TAG_OPEN } from "./constants"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
CommandExecuteBeforeInput,
CommandExecuteBeforeOutput,
} from "./types"
import * as shared from "../../shared"
const executeSlashCommandMock = mock(
async (parsed: { command: string; args: string; raw: string }) => ({
success: true,
replacementText: parsed.raw,
})
)
mock.module("./executor", () => ({
executeSlashCommand: executeSlashCommandMock,
}))
const logMock = spyOn(shared, "log").mockImplementation(() => {})
const { createAutoSlashCommandHook } = await import("./hook")
function createChatInput(sessionID: string, messageID: string): AutoSlashCommandHookInput {
return {
sessionID,
messageID,
}
}
function createChatOutput(text: string): AutoSlashCommandHookOutput {
return {
message: {},
parts: [{ type: "text", text }],
}
}
function createCommandInput(sessionID: string, command: string): CommandExecuteBeforeInput {
return {
sessionID,
command,
arguments: "",
}
}
function createCommandOutput(text: string): CommandExecuteBeforeOutput {
return {
parts: [{ type: "text", text }],
}
}
describe("createAutoSlashCommandHook leak prevention", () => {
beforeEach(() => {
executeSlashCommandMock.mockClear()
logMock.mockClear()
})
describe("#given hook with sessionProcessedCommandExecutions", () => {
describe("#when same command executed twice for same session", () => {
it("#then second execution is deduplicated", async () => {
const hook = createAutoSlashCommandHook()
const input = createCommandInput("session-dedup", "leak-test-command")
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
await hook["command.execute.before"](input, firstOutput)
await hook["command.execute.before"](input, secondOutput)
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toBe("second")
})
})
})
describe("#given hook with entries from multiple sessions", () => {
describe("#when dispose() is called", () => {
it("#then both Sets are empty", async () => {
const hook = createAutoSlashCommandHook()
await hook["chat.message"](
createChatInput("session-chat", "message-chat"),
createChatOutput("/leak-chat")
)
await hook["command.execute.before"](
createCommandInput("session-command", "leak-command"),
createCommandOutput("before")
)
executeSlashCommandMock.mockClear()
hook.dispose()
const chatOutputAfterDispose = createChatOutput("/leak-chat")
const commandOutputAfterDispose = createCommandOutput("after")
await hook["chat.message"](
createChatInput("session-chat", "message-chat"),
chatOutputAfterDispose
)
await hook["command.execute.before"](
createCommandInput("session-command", "leak-command"),
commandOutputAfterDispose
)
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
expect(chatOutputAfterDispose.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(commandOutputAfterDispose.parts[0].text).toContain(
AUTO_SLASH_COMMAND_TAG_OPEN
)
})
})
})
describe("#given Set with more than 10000 entries", () => {
describe("#when new entry added", () => {
it("#then Set size is reduced", async () => {
const hook = createAutoSlashCommandHook()
const oldestInput = createChatInput("session-oldest", "message-oldest")
await hook["chat.message"](oldestInput, createChatOutput("/leak-oldest"))
for (let index = 0; index < 10000; index += 1) {
await hook["chat.message"](
createChatInput(`session-${index}`, `message-${index}`),
createChatOutput(`/leak-${index}`)
)
}
const newestInput = createChatInput("session-newest", "message-newest")
await hook["chat.message"](newestInput, createChatOutput("/leak-newest"))
executeSlashCommandMock.mockClear()
const oldestRetryOutput = createChatOutput("/leak-oldest")
const newestRetryOutput = createChatOutput("/leak-newest")
await hook["chat.message"](oldestInput, oldestRetryOutput)
await hook["chat.message"](newestInput, newestRetryOutput)
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(oldestRetryOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(newestRetryOutput.parts[0].text).toBe("/leak-newest")
})
})
})
})
+46 -4
View File
@@ -9,6 +9,7 @@ import {
AUTO_SLASH_COMMAND_TAG_CLOSE,
AUTO_SLASH_COMMAND_TAG_OPEN,
} from "./constants"
import { createProcessedCommandStore } from "./processed-command-store"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
@@ -17,8 +18,22 @@ import type {
} from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
const sessionProcessedCommands = new Set<string>()
const sessionProcessedCommandExecutions = new Set<string>()
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getDeletedSessionID(properties: unknown): string | null {
if (!isRecord(properties)) {
return null
}
const info = properties.info
if (!isRecord(info)) {
return null
}
return typeof info.id === "string" ? info.id : null
}
export interface AutoSlashCommandHookOptions {
skills?: LoadedSkill[]
@@ -32,6 +47,13 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
pluginsEnabled: options?.pluginsEnabled,
enabledPluginsOverride: options?.enabledPluginsOverride,
}
const sessionProcessedCommands = createProcessedCommandStore()
const sessionProcessedCommandExecutions = createProcessedCommandStore()
const dispose = (): void => {
sessionProcessedCommands.clear()
sessionProcessedCommandExecutions.clear()
}
return {
"chat.message": async (
@@ -61,7 +83,9 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
return
}
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
const commandKey = input.messageID
? `${input.sessionID}:${input.messageID}:${parsed.command}`
: `${input.sessionID}:${parsed.command}`
if (sessionProcessedCommands.has(commandKey)) {
return
}
@@ -101,7 +125,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
input: CommandExecuteBeforeInput,
output: CommandExecuteBeforeOutput
): Promise<void> => {
const commandKey = `${input.sessionID}:${input.command}:${Date.now()}`
const commandKey = `${input.sessionID}:${input.command.toLowerCase()}:${input.arguments || ""}`
if (sessionProcessedCommandExecutions.has(commandKey)) {
return
}
@@ -145,5 +169,23 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
command: input.command,
})
},
event: async ({
event,
}: {
event: { type: string; properties?: unknown }
}): Promise<void> => {
if (event.type !== "session.deleted") {
return
}
const sessionID = getDeletedSessionID(event.properties)
if (!sessionID) {
return
}
sessionProcessedCommands.cleanupSession(sessionID)
sessionProcessedCommandExecutions.cleanupSession(sessionID)
},
dispose,
}
}
@@ -0,0 +1,41 @@
const MAX_PROCESSED_ENTRY_COUNT = 10_000
function trimProcessedEntries(entries: Set<string>): Set<string> {
if (entries.size <= MAX_PROCESSED_ENTRY_COUNT) {
return entries
}
return new Set(Array.from(entries).slice(Math.floor(entries.size / 2)))
}
function removeSessionEntries(entries: Set<string>, sessionID: string): Set<string> {
const sessionPrefix = `${sessionID}:`
return new Set(Array.from(entries).filter((entry) => !entry.startsWith(sessionPrefix)))
}
export interface ProcessedCommandStore {
has(commandKey: string): boolean
add(commandKey: string): void
cleanupSession(sessionID: string): void
clear(): void
}
export function createProcessedCommandStore(): ProcessedCommandStore {
let entries = new Set<string>()
return {
has(commandKey: string): boolean {
return entries.has(commandKey)
},
add(commandKey: string): void {
entries.add(commandKey)
entries = trimProcessedEntries(entries)
},
cleanupSession(sessionID: string): void {
entries = removeSessionEntries(entries, sessionID)
},
clear(): void {
entries.clear()
},
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { HookDeps } from "./types"
import type { HookDeps, RuntimeFallbackTimeout } from "./types"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
@@ -9,8 +9,8 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
const SESSION_TTL_MS = 30 * 60 * 1000
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): ReturnType<typeof globalThis.setTimeout>
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
export function createAutoRetryHelpers(deps: HookDeps) {
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
+160
View File
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { HookDeps, RuntimeFallbackPluginInput } from "./types"
let capturedDeps: HookDeps | undefined
const mockCreateAutoRetryHelpers = mock((deps: HookDeps) => {
capturedDeps = deps
return {
abortSessionRequest: async () => {},
clearSessionFallbackTimeout: () => {},
scheduleSessionFallbackTimeout: () => {},
autoRetryWithFallback: async () => {},
resolveAgentForSessionFromContext: async () => undefined,
cleanupStaleSessions: () => {},
}
})
const mockCreateEventHandler = mock(() => async () => {})
const mockCreateMessageUpdateHandler = mock(() => async () => {})
const mockCreateChatMessageHandler = mock(() => async () => {})
mock.module("./auto-retry", () => ({
createAutoRetryHelpers: mockCreateAutoRetryHelpers,
}))
mock.module("./event-handler", () => ({
createEventHandler: mockCreateEventHandler,
}))
mock.module("./message-update-handler", () => ({
createMessageUpdateHandler: mockCreateMessageUpdateHandler,
}))
mock.module("./chat-message-handler", () => ({
createChatMessageHandler: mockCreateChatMessageHandler,
}))
const { createRuntimeFallbackHook } = await import("./hook")
function createMockContext(): RuntimeFallbackPluginInput {
return {
client: {
session: {
abort: async () => ({}),
messages: async () => ({}),
promptAsync: async () => ({}),
},
tui: {
showToast: async () => ({}),
},
},
directory: "/test",
}
}
describe("createRuntimeFallbackHook dispose", () => {
const originalSetInterval = globalThis.setInterval
const originalClearInterval = globalThis.clearInterval
const originalClearTimeout = globalThis.clearTimeout
const createdIntervals: Array<ReturnType<typeof originalSetInterval>> = []
const clearedIntervals: Array<Parameters<typeof originalClearInterval>[0]> = []
const clearedTimeouts: Array<Parameters<typeof originalClearTimeout>[0]> = []
const timeoutMapSizesDuringClear: number[] = []
beforeEach(() => {
capturedDeps = undefined
createdIntervals.length = 0
clearedIntervals.length = 0
clearedTimeouts.length = 0
timeoutMapSizesDuringClear.length = 0
mockCreateAutoRetryHelpers.mockClear()
mockCreateEventHandler.mockClear()
mockCreateMessageUpdateHandler.mockClear()
mockCreateChatMessageHandler.mockClear()
const wrappedSetInterval = ((handler: () => void, timeout?: number) => {
const interval = originalSetInterval(handler, timeout)
createdIntervals.push(interval)
return interval
}) as typeof globalThis.setInterval
const wrappedClearInterval = ((interval?: Parameters<typeof clearInterval>[0]) => {
clearedIntervals.push(interval)
return originalClearInterval(interval)
}) as typeof globalThis.clearInterval
const wrappedClearTimeout = ((timeout?: Parameters<typeof clearTimeout>[0]) => {
timeoutMapSizesDuringClear.push(capturedDeps?.sessionFallbackTimeouts.size ?? -1)
clearedTimeouts.push(timeout)
return originalClearTimeout(timeout)
}) as typeof globalThis.clearTimeout
globalThis.setInterval = wrappedSetInterval
globalThis.clearInterval = wrappedClearInterval
globalThis.clearTimeout = wrappedClearTimeout
})
afterEach(() => {
globalThis.setInterval = originalSetInterval
globalThis.clearInterval = originalClearInterval
globalThis.clearTimeout = originalClearTimeout
})
test("#given runtime-fallback hook created #when dispose() is called #then cleanup interval is cleared", () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
// when
hook.dispose?.()
// then
expect(createdIntervals).toHaveLength(1)
expect(clearedIntervals).toEqual([createdIntervals[0]])
})
test("#given hook with session state data #when dispose() is called #then all Maps and Sets are empty", () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
const fallbackTimeout = setTimeout(() => {}, 60_000)
capturedDeps?.sessionStates.set("session-1", {
originalModel: "anthropic/claude-opus-4-6",
currentModel: "openai/gpt-5.4",
fallbackIndex: 1,
failedModels: new Map([["anthropic/claude-opus-4-6", 1]]),
attemptCount: 1,
})
capturedDeps?.sessionLastAccess.set("session-1", Date.now())
capturedDeps?.sessionRetryInFlight.add("session-1")
capturedDeps?.sessionAwaitingFallbackResult.add("session-1")
capturedDeps?.sessionFallbackTimeouts.set("session-1", fallbackTimeout)
// when
hook.dispose?.()
// then
expect(capturedDeps?.sessionStates.size).toBe(0)
expect(capturedDeps?.sessionLastAccess.size).toBe(0)
expect(capturedDeps?.sessionRetryInFlight.size).toBe(0)
expect(capturedDeps?.sessionAwaitingFallbackResult.size).toBe(0)
expect(capturedDeps?.sessionFallbackTimeouts.size).toBe(0)
})
test("#given hook with pending fallback timeouts #when dispose() is called #then timeouts are cleared before Map is emptied", () => {
// given
const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} })
const fallbackTimeout = setTimeout(() => {}, 60_000)
capturedDeps?.sessionFallbackTimeouts.set("session-1", fallbackTimeout)
// when
hook.dispose?.()
// then
expect(clearedTimeouts).toEqual([fallbackTimeout])
expect(timeoutMapSizesDuringClear).toEqual([1])
expect(capturedDeps?.sessionFallbackTimeouts.size).toBe(0)
})
})
+21 -3
View File
@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackOptions } from "./types"
import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types"
import { DEFAULT_CONFIG, HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { loadPluginConfig } from "../../plugin-config"
@@ -8,8 +7,12 @@ import { createEventHandler } from "./event-handler"
import { createMessageUpdateHandler } from "./message-update-handler"
import { createChatMessageHandler } from "./chat-message-handler"
declare function setInterval(callback: () => void, delay?: number): RuntimeFallbackInterval
declare function clearInterval(interval: RuntimeFallbackInterval): void
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
export function createRuntimeFallbackHook(
ctx: PluginInput,
ctx: RuntimeFallbackPluginInput,
options?: RuntimeFallbackOptions
): RuntimeFallbackHook {
const config = {
@@ -60,8 +63,23 @@ export function createRuntimeFallbackHook(
await baseEventHandler({ event })
}
const dispose = () => {
clearInterval(cleanupInterval)
for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) {
clearTimeout(fallbackTimeout)
}
deps.sessionStates.clear()
deps.sessionLastAccess.clear()
deps.sessionRetryInFlight.clear()
deps.sessionAwaitingFallbackResult.clear()
deps.sessionFallbackTimeouts.clear()
}
return {
event: eventHandler,
"chat.message": chatMessageHandler,
dispose,
} as RuntimeFallbackHook
}
+38 -3
View File
@@ -1,6 +1,40 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
export interface RuntimeFallbackInterval {
unref: () => void
}
export type RuntimeFallbackTimeout = object | number
export interface RuntimeFallbackPluginInput {
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>
messages: (input: { path: { id: string }; query: { directory: string } }) => Promise<unknown>
promptAsync: (input: {
path: { id: string }
body: {
agent?: string
model: { providerID: string; modelID: string }
parts: Array<{ type: "text"; text: string }>
}
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "success" | "error" | "info" | "warning"
duration: number
}
}) => Promise<unknown>
}
}
directory: string
}
export interface FallbackState {
originalModel: string
currentModel: string
@@ -26,10 +60,11 @@ export interface RuntimeFallbackOptions {
export interface RuntimeFallbackHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
"chat.message"?: (input: { sessionID: string; agent?: string; model?: { providerID: string; modelID: string } }, output: { message: { model?: { providerID: string; modelID: string } }; parts?: Array<{ type: string; text?: string }> }) => Promise<void>
dispose?: () => void
}
export interface HookDeps {
ctx: PluginInput
ctx: RuntimeFallbackPluginInput
config: Required<RuntimeFallbackConfig>
options: RuntimeFallbackOptions | undefined
pluginConfig: OhMyOpenCodeConfig | undefined
@@ -37,5 +72,5 @@ export interface HookDeps {
sessionLastAccess: Map<string, number>
sessionRetryInFlight: Set<string>
sessionAwaitingFallbackResult: Set<string>
sessionFallbackTimeouts: Map<string, ReturnType<typeof setTimeout>>
sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout>
}
@@ -0,0 +1,101 @@
declare module "bun:test" {
export interface Matchers {
toBeDefined(): void
toBeUndefined(): void
toHaveLength(expected: number): void
}
}
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"
import * as actualSessionStateModule from "./session-state"
import type { SessionStateStore } from "./session-state"
let createdSessionStateStore: SessionStateStore | undefined
const createActualSessionStateStore = actualSessionStateModule.createSessionStateStore
const mockModule = mock as typeof mock & {
module: (specifier: string, factory: () => unknown) => void
}
mockModule.module("./session-state", () => ({
...actualSessionStateModule,
createSessionStateStore: () => {
const sessionStateStore = createActualSessionStateStore()
createdSessionStateStore = sessionStateStore
return sessionStateStore
},
}))
const { createTodoContinuationEnforcer } = await import(".")
type PluginInput = Parameters<typeof createTodoContinuationEnforcer>[0]
function createMockPluginInput(): PluginInput {
return {
directory: "/tmp/test",
} as PluginInput
}
function getCreatedSessionStateStore(): SessionStateStore {
if (!createdSessionStateStore) {
throw new Error("expected session state store to be created")
}
return createdSessionStateStore
}
describe("todo-continuation-enforcer dispose", () => {
afterEach(() => {
createdSessionStateStore?.shutdown()
createdSessionStateStore = undefined
})
afterAll(() => {
mockModule.module("./session-state", () => actualSessionStateModule)
})
it("#given todo-continuation-enforcer created #when dispose exists on return value #then it is a function", () => {
// given
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
// when
const { dispose } = enforcer
// then
expect(typeof dispose).toBe("function")
enforcer.dispose()
})
it("#given enforcer with active session states #when dispose is called #then internal session state store is shut down", () => {
// given
const originalClearInterval = globalThis.clearInterval
const clearIntervalCalls: Array<Parameters<typeof clearInterval>[0]> = []
globalThis.clearInterval = ((timer?: Parameters<typeof clearInterval>[0]) => {
clearIntervalCalls.push(timer)
return originalClearInterval(timer)
}) as typeof clearInterval
try {
const enforcer = createTodoContinuationEnforcer(createMockPluginInput())
const sessionStateStore = getCreatedSessionStateStore()
enforcer.markRecovering("session-1")
enforcer.markRecovering("session-2")
expect(sessionStateStore.getExistingState("session-1")).toBeDefined()
expect(sessionStateStore.getExistingState("session-2")).toBeDefined()
// when
enforcer.dispose()
// then
expect(clearIntervalCalls).toHaveLength(1)
expect(sessionStateStore.getExistingState("session-1")).toBeUndefined()
expect(sessionStateStore.getExistingState("session-2")).toBeUndefined()
} finally {
globalThis.clearInterval = originalClearInterval
}
})
})
@@ -56,5 +56,6 @@ export function createTodoContinuationEnforcer(
markRecovering,
markRecoveryComplete,
cancelAllCountdowns,
dispose: () => sessionStateStore.shutdown(),
}
}
@@ -13,6 +13,7 @@ export interface TodoContinuationEnforcer {
markRecovering: (sessionID: string) => void
markRecoveryComplete: (sessionID: string) => void
cancelAllCountdowns: () => void
dispose: () => void
}
export interface Todo {
+12
View File
@@ -7,6 +7,7 @@ import { createHooks } from "./create-hooks"
import { createManagers } from "./create-managers"
import { createTools } from "./create-tools"
import { createPluginInterface } from "./plugin-interface"
import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
@@ -14,6 +15,8 @@ import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { injectServerAuthIntoClient, log } from "./shared"
import { startTmuxCheck } from "./tools"
let activePluginDispose: PluginDispose | null = null
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
// Initialize config context for plugin runtime (prevents warnings from hooks)
initConfigContext("opencode", null)
@@ -23,6 +26,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
injectServerAuthIntoClient(ctx.client)
startTmuxCheck()
await activePluginDispose?.()
const pluginConfig = loadPluginConfig(ctx.directory, ctx)
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? [])
@@ -67,6 +71,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
availableSkills: toolsResult.availableSkills,
})
const dispose = createPluginDispose({
backgroundManager: managers.backgroundManager,
skillMcpManager: managers.skillMcpManager,
disposeHooks: hooks.disposeHooks,
})
const pluginInterface = createPluginInterface({
ctx,
pluginConfig,
@@ -76,6 +86,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
tools: toolsResult.filteredTools,
})
activePluginDispose = dispose
return {
...pluginInterface,
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, spyOn, test } from "bun:test"
import { disposeCreatedHooks } from "./create-hooks"
import { createPluginDispose } from "./plugin-dispose"
describe("createPluginDispose", () => {
test("#given plugin with active managers and hooks #when dispose() is called #then backgroundManager.shutdown() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with active MCP connections #when dispose() is called #then skillMcpManager.disconnectAll() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with hooks that have dispose #when dispose() is called #then each hook's dispose is called", async () => {
// given
const runtimeFallback = {
dispose: (): void => {},
}
const todoContinuationEnforcer = {
dispose: (): void => {},
}
const autoSlashCommand = {
dispose: (): void => {},
}
const runtimeFallbackDisposeSpy = spyOn(runtimeFallback, "dispose")
const todoContinuationEnforcerDisposeSpy = spyOn(todoContinuationEnforcer, "dispose")
const autoSlashCommandDisposeSpy = spyOn(autoSlashCommand, "dispose")
const dispose = createPluginDispose({
backgroundManager: {
shutdown: async (): Promise<void> => {},
},
skillMcpManager: {
disconnectAll: async (): Promise<void> => {},
},
disposeHooks: (): void => {
disposeCreatedHooks({
runtimeFallback,
todoContinuationEnforcer,
autoSlashCommand,
})
},
})
// when
await dispose()
// then
expect(runtimeFallbackDisposeSpy).toHaveBeenCalledTimes(1)
expect(todoContinuationEnforcerDisposeSpy).toHaveBeenCalledTimes(1)
expect(autoSlashCommandDisposeSpy).toHaveBeenCalledTimes(1)
})
test("#given dispose already called #when dispose() called again #then no errors", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const disposeHooks = {
run: (): void => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const disposeHooksSpy = spyOn(disposeHooks, "run")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
disposeHooks: disposeHooks.run,
})
// when
await dispose()
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksSpy).toHaveBeenCalledTimes(1)
})
test("#given backgroundManager.shutdown() throws #when dispose() is called #then skillMcpManager.disconnectAll() and disposeHooks() are still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {
throw new Error("shutdown failed")
},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const disposeHooksCalls: number[] = []
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
test("#given skillMcpManager.disconnectAll() throws #when dispose() is called #then disposeHooks() is still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {
throw new Error("disconnectAll failed")
},
}
const disposeHooksCalls: number[] = []
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { log } from "./shared"
export type PluginDispose = () => Promise<void>
export function createPluginDispose(args: {
backgroundManager: {
shutdown: () => void | Promise<void>
}
skillMcpManager: {
disconnectAll: () => Promise<void>
}
disposeHooks: () => void
}): PluginDispose {
const { backgroundManager, skillMcpManager, disposeHooks } = args
let disposePromise: Promise<void> | null = null
return async (): Promise<void> => {
if (disposePromise) {
await disposePromise
return
}
disposePromise = (async (): Promise<void> => {
try {
await backgroundManager.shutdown()
} catch (error) {
log("[plugin-dispose] backgroundManager.shutdown() error:", error)
}
try {
await skillMcpManager.disconnectAll()
} catch (error) {
log("[plugin-dispose] skillMcpManager.disconnectAll() error:", error)
}
try {
disposeHooks()
} catch (error) {
log("[plugin-dispose] disposeHooks() error:", error)
}
})()
await disposePromise
}
}
+1
View File
@@ -190,6 +190,7 @@ export function createEventHandler(args: {
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
await Promise.resolve(hooks.autoSlashCommand?.event?.(input));
};
const recentSyntheticIdles = new Map<string, number>();
@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import {
_resetForTesting,
subagentSessions,
syncSubagentSessions,
} from "../../features/claude-code-session-state"
import { executeSync } from "./sync-executor"
type ExecuteSyncArgs = Parameters<typeof executeSync>[0]
type ExecuteSyncToolContext = Parameters<typeof executeSync>[1]
type ExecuteSyncDeps = NonNullable<Parameters<typeof executeSync>[3]>
function createArgs(): ExecuteSyncArgs {
return {
subagent_type: "explore",
description: "cleanup leak",
prompt: "find something",
run_in_background: false,
}
}
function createToolContext(): ExecuteSyncToolContext {
return {
sessionID: "parent-session",
messageID: "msg-1",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: mock(async () => {}),
}
}
function createContext(promptAsync: ReturnType<typeof mock>) {
return {
client: {
session: {
promptAsync,
},
},
}
}
function createDependencies(overrides?: Partial<ExecuteSyncDeps>): ExecuteSyncDeps {
return {
createOrGetSession: mock(async () => ({ sessionID: "ses-default", isNew: true })),
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
setSessionFallbackChain: mock(() => {}),
clearSessionFallbackChain: mock(() => {}),
...overrides,
}
}
describe("executeSync session cleanup", () => {
beforeEach(() => {
_resetForTesting()
})
afterEach(() => {
_resetForTesting()
})
describe("#given executeSync creates a session", () => {
test("#when execution completes successfully #then sessionID is removed from subagentSessions and syncSubagentSessions", async () => {
// given
const sessionID = "ses-cleanup-success"
const args = createArgs()
const toolContext = createToolContext()
const promptAsync = mock(async () => ({ data: {} }))
const deps = createDependencies({
createOrGetSession: mock(async () => {
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
return { sessionID, isNew: true }
}),
waitForCompletion: mock(async (createdSessionID: string) => {
expect(createdSessionID).toBe(sessionID)
expect(subagentSessions.has(sessionID)).toBe(true)
expect(syncSubagentSessions.has(sessionID)).toBe(true)
}),
})
expect(subagentSessions.has(sessionID)).toBe(false)
expect(syncSubagentSessions.has(sessionID)).toBe(false)
// when
const result = await executeSync(args, toolContext, createContext(promptAsync) as never, deps)
// then
expect(result).toContain(`session_id: ${sessionID}`)
expect(subagentSessions.has(sessionID)).toBe(false)
expect(syncSubagentSessions.has(sessionID)).toBe(false)
})
test("#when execution throws an error #then sessionID is still removed from both Sets", async () => {
// given
const sessionID = "ses-cleanup-error"
const args = createArgs()
const toolContext = createToolContext()
const promptAsync = mock(async () => ({ data: {} }))
const deps = createDependencies({
createOrGetSession: mock(async () => {
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
return { sessionID, isNew: true }
}),
waitForCompletion: mock(async (createdSessionID: string) => {
expect(createdSessionID).toBe(sessionID)
expect(subagentSessions.has(sessionID)).toBe(true)
expect(syncSubagentSessions.has(sessionID)).toBe(true)
throw new Error("poll exploded")
}),
})
// when
const resultPromise = executeSync(args, toolContext, createContext(promptAsync) as never, deps)
// then
let thrownError: Error | undefined
try {
await resultPromise
} catch (error) {
if (error instanceof Error) {
thrownError = error
} else {
throw error
}
}
expect(thrownError?.message).toBe("poll exploded")
expect(subagentSessions.has(sessionID)).toBe(false)
expect(syncSubagentSessions.has(sessionID)).toBe(false)
})
})
describe("#given executeSync reuses an existing session", () => {
test("#when execution completes successfully #then the reused session is tracked in both Sets", async () => {
// given
const sessionID = "ses-reused"
const args = { ...createArgs(), session_id: sessionID }
const toolContext = createToolContext()
const promptAsync = mock(async () => ({ data: {} }))
const deps = createDependencies({
createOrGetSession: mock(async () => ({ sessionID, isNew: false })),
waitForCompletion: mock(async (createdSessionID: string) => {
expect(createdSessionID).toBe(sessionID)
expect(subagentSessions.has(sessionID)).toBe(true)
expect(syncSubagentSessions.has(sessionID)).toBe(true)
}),
})
expect(subagentSessions.has(sessionID)).toBe(false)
expect(syncSubagentSessions.has(sessionID)).toBe(false)
// when
const result = await executeSync(args, toolContext, createContext(promptAsync) as never, deps)
// then
expect(result).toContain(`session_id: ${sessionID}`)
expect(subagentSessions.has(sessionID)).toBe(true)
expect(syncSubagentSessions.has(sessionID)).toBe(true)
})
test("#when execution applies a fallback chain #then it clears that chain in finally", async () => {
// given
const sessionID = "ses-reused-fallback"
const args = { ...createArgs(), session_id: sessionID }
const toolContext = createToolContext()
const promptAsync = mock(async () => ({ data: {} }))
const clearSessionFallbackChain = mock(() => {})
const deps = createDependencies({
createOrGetSession: mock(async () => ({ sessionID, isNew: false })),
clearSessionFallbackChain,
})
const fallbackChain = [{ providers: ["openai"], model: "gpt-5.4" }]
// when
await executeSync(args, toolContext, createContext(promptAsync) as never, deps, fallbackChain)
// then
expect(clearSessionFallbackChain).toHaveBeenCalledWith(sessionID)
})
})
})
@@ -24,6 +24,7 @@ type Dependencies = {
waitForCompletion: ReturnType<typeof mock>
processMessages: ReturnType<typeof mock>
setSessionFallbackChain: ReturnType<typeof mock>
clearSessionFallbackChain: ReturnType<typeof mock>
}
async function importExecuteSync(): Promise<ExecuteSync> {
@@ -37,6 +38,7 @@ function createDependencies(overrides?: Partial<Dependencies>): Dependencies {
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
setSessionFallbackChain: mock(() => {}),
clearSessionFallbackChain: mock(() => {}),
...overrides,
}
}
@@ -259,6 +261,7 @@ describe("executeSync", () => {
waitForCompletion: mock(async () => {}),
processMessages: mock(async () => "agent response"),
setSessionFallbackChain: mock(() => {}),
clearSessionFallbackChain: mock(() => {}),
}
const spawnReservation = {
+28 -12
View File
@@ -1,12 +1,12 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { getAgentToolRestrictions, log } from "../../shared"
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentToolRestrictions } from "../../shared"
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { createOrGetSession } from "./session-creator"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
import { createOrGetSession } from "./session-creator"
type SessionWithPromptAsync = {
promptAsync: (opts: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
@@ -17,6 +17,7 @@ type ExecuteSyncDeps = {
waitForCompletion: typeof waitForCompletion
processMessages: typeof processMessages
setSessionFallbackChain: typeof setSessionFallbackChain
clearSessionFallbackChain: typeof clearSessionFallbackChain
}
type SpawnReservation = {
@@ -29,6 +30,7 @@ const defaultDeps: ExecuteSyncDeps = {
waitForCompletion,
processMessages,
setSessionFallbackChain,
clearSessionFallbackChain,
}
export async function executeSync(
@@ -46,10 +48,15 @@ export async function executeSync(
spawnReservation?: SpawnReservation,
): Promise<string> {
let sessionID: string | undefined
let createdSessionForExecution = false
let appliedFallbackChain = false
try {
const session = await deps.createOrGetSession(args, toolContext, ctx)
sessionID = session.sessionID
createdSessionForExecution = session.isNew
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
if (session.isNew) {
spawnReservation?.commit()
@@ -57,12 +64,15 @@ export async function executeSync(
if (fallbackChain && fallbackChain.length > 0) {
deps.setSessionFallbackChain(sessionID, fallbackChain)
appliedFallbackChain = true
}
await toolContext.metadata?.({
title: args.description,
metadata: { sessionId: sessionID },
})
await Promise.resolve(
toolContext.metadata?.({
title: args.description,
metadata: { sessionId: sessionID },
})
)
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
@@ -93,12 +103,18 @@ export async function executeSync(
const responseText = await deps.processMessages(sessionID, ctx)
const output =
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
return output
return responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
} catch (error) {
spawnReservation?.rollback()
throw error
} finally {
if (sessionID && appliedFallbackChain) {
deps.clearSessionFallbackChain(sessionID)
}
if (sessionID && createdSessionForExecution) {
subagentSessions.delete(sessionID)
syncSubagentSessions.delete(sessionID)
}
}
}