fix(tmux-subagent): tighten serve/attach cleanup paths so panes and sessions are torn down reliably

Three defects observed with tmux.isolation="session" where the omo-agents
session was left with orphan fish panes after subagents finished:

1. cleanup() never ran 'tmux kill-session -t omo-agents'. If any pane
   lingered (for example because opencode attach stayed blocked on SSE),
   the isolated session survived process shutdown. Now we explicitly kill
   the shared session through killTmuxSessionIfExists when isolation is
   "session".

2. session.error events bypassed tmux cleanup entirely. Only session.deleted
   closed panes, so any provider error that did not escalate into a delete
   left the pane behind. Added onSessionError on TmuxSessionManager, wired
   from plugin/event.ts, which funnels through the same onSessionDeleted
   close path for tracked sessions only.

3. retryPendingCloses() only ran when a new session was created. If the
   main process went idle after a failed close, the pending session stayed
   pending forever. TmuxPollingManager now accepts the retry callback and
   fires it on every tick, alongside the existing stability-based close
   sweep.

Manager tests cover isolation=session kill invocation, inline/window
isolation skipping the kill, the onSessionError happy + untracked paths,
and an isolated-session kill failure that must not break cleanup.
This commit is contained in:
YeonGyu-Kim
2026-04-18 19:31:45 +09:00
parent de8a0167e6
commit 21554be870
4 changed files with 154 additions and 2 deletions
+109
View File
@@ -57,6 +57,7 @@ const mockSpawnTmuxSession = mock<(
success: true,
paneId: '%isolated-session',
}))
const mockKillTmuxSessionIfExists = mock<(sessionName: string) => Promise<boolean>>(async () => true)
const mockIsInsideTmux = mock<() => boolean>(() => true)
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
@@ -99,6 +100,8 @@ mock.module('../../shared/tmux', () => {
SESSION_READY_TIMEOUT_MS: 500,
spawnTmuxWindow: mockSpawnTmuxWindow,
spawnTmuxSession: mockSpawnTmuxSession,
killTmuxSessionIfExists: mockKillTmuxSessionIfExists,
ISOLATED_SESSION_NAME: 'omo-agents',
}
})
@@ -1852,6 +1855,112 @@ describe('TmuxSessionManager', () => {
// then
expect(mockExecuteAction).toHaveBeenCalledTimes(2)
})
test('#given tmux isolation is "session" #when cleanup runs #then killTmuxSessionIfExists is invoked for the isolated session', async () => {
// given
mockKillTmuxSessionIfExists.mockClear()
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'session',
}), mockTmuxDeps)
// when
await manager.cleanup()
// then
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(1)
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledWith('omo-agents')
})
test('#given tmux isolation is "inline" #when cleanup runs #then killTmuxSessionIfExists is NOT invoked', async () => {
// given
mockKillTmuxSessionIfExists.mockClear()
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'inline',
}), mockTmuxDeps)
// when
await manager.cleanup()
// then
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(0)
})
test('#given tmux isolation is "window" #when cleanup runs #then killTmuxSessionIfExists is NOT invoked', async () => {
// given
mockKillTmuxSessionIfExists.mockClear()
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'window',
}), mockTmuxDeps)
// when
await manager.cleanup()
// then
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(0)
})
test('#given a tracked session #when onSessionError is invoked #then the pane is closed like onSessionDeleted', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockExecuteAction.mockClear()
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'session',
}), mockTmuxDeps)
await manager.onSessionCreated(createSessionCreatedEvent('ses_err', 'ses_parent', 'Errored Task'))
mockExecuteAction.mockClear()
// when
await manager.onSessionError({ sessionID: 'ses_err' })
// then
expect(mockExecuteAction).toHaveBeenCalled()
})
test('#given an untracked session #when onSessionError is invoked #then it is a no-op and does not throw', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockExecuteAction.mockClear()
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'session',
}), mockTmuxDeps)
// when
const errorHandler = manager.onSessionError({ sessionID: 'ses_unknown' })
// then
await expect(errorHandler).resolves.toBeUndefined()
expect(mockExecuteAction).not.toHaveBeenCalled()
})
test('#given killTmuxSessionIfExists throws #when cleanup runs #then cleanup still completes without throwing', async () => {
// given
mockKillTmuxSessionIfExists.mockClear()
mockKillTmuxSessionIfExists.mockImplementationOnce(async () => {
throw new Error('simulated teardown failure')
})
const { TmuxSessionManager } = await import('./manager')
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({
enabled: true,
isolation: 'session',
}), mockTmuxDeps)
// when
const cleanupPromise = manager.cleanup()
// then
await expect(cleanupPromise).resolves.toBeUndefined()
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(1)
})
})
})