Merge pull request #3059 from code-yeongyu/fix/p2-13-anchor-pane-cleanup

fix(tmux): reassign anchor pane on first subagent deletion
This commit is contained in:
YeonGyu-Kim
2026-04-03 18:55:30 +09:00
committed by GitHub
2 changed files with 134 additions and 4 deletions
+106
View File
@@ -1091,6 +1091,112 @@ describe('TmuxSessionManager', () => {
paneId: '%isolated-session-ses_first',
sessionId: 'ses_first',
})
expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBeUndefined()
expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined()
})
test('#given session isolation with another subagent still tracked #when the anchor subagent is deleted first #then it reassigns the anchor and cleans up when the last subagent exits', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async (paneId) => {
if (paneId === '%isolated-session-ses_first') {
return createWindowState({
mainPane: {
paneId,
width: 110,
height: 44,
left: 0,
top: 0,
title: 'isolated',
isActive: true,
},
agentPanes: [
{
paneId: '%mock',
width: 40,
height: 44,
left: 110,
top: 0,
title: 'omo-subagent-Second Task',
isActive: false,
},
],
})
}
if (paneId === '%mock') {
return createWindowState({
mainPane: {
paneId: '%isolated-session-ses_first',
width: 110,
height: 44,
left: 0,
top: 0,
title: 'isolated',
isActive: true,
},
agentPanes: [
{
paneId,
width: 40,
height: 44,
left: 110,
top: 0,
title: 'omo-subagent-Second Task',
isActive: false,
},
],
})
}
return createWindowState()
})
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
isolation: 'session',
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_first', 'ses_parent', 'First Task')
)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_second', 'ses_parent', 'Second Task')
)
mockExecuteAction.mockClear()
// when
await manager.onSessionDeleted({ sessionID: 'ses_first' })
// then
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBe('%isolated-session-ses_first')
expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBe('%mock')
// when
await manager.onSessionDeleted({ sessionID: 'ses_second' })
// then
expect(mockExecuteAction).toHaveBeenCalledTimes(2)
expect(mockExecuteAction.mock.calls[0]?.[0]).toEqual({
type: 'close',
paneId: '%mock',
sessionId: 'ses_second',
})
expect(mockExecuteAction.mock.calls[1]?.[0]).toEqual({
type: 'close',
paneId: '%isolated-session-ses_first',
sessionId: 'ses_second',
})
expect(Reflect.get(manager, 'isolatedContainerPaneId')).toBeUndefined()
expect(Reflect.get(manager, 'isolatedWindowPaneId')).toBeUndefined()
})
+28 -4
View File
@@ -70,6 +70,7 @@ export class TmuxSessionManager {
private nullStateCount = 0
private deps: TmuxUtilDeps
private pollingManager: TmuxPollingManager
private isolatedContainerPaneId: string | undefined
private isolatedWindowPaneId: string | undefined
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
this.client = ctx.client
@@ -125,6 +126,7 @@ export class TmuxSessionManager {
if (this.isolatedWindowPaneId) {
const state = await queryWindowState(this.isolatedWindowPaneId).catch(() => null)
if (state) return null
this.isolatedContainerPaneId = undefined
this.isolatedWindowPaneId = undefined
}
@@ -136,6 +138,7 @@ export class TmuxSessionManager {
: await spawnTmuxWindow(sessionId, title, this.tmuxConfig, this.serverUrl)
if (result.success && result.paneId) {
this.isolatedContainerPaneId = result.paneId
this.isolatedWindowPaneId = result.paneId
log("[tmux-session-manager] isolated container created", {
isolation,
@@ -172,6 +175,19 @@ export class TmuxSessionManager {
}
}
private reassignIsolatedContainerAnchor(): void {
const nextAnchor = this.sessions.values().next().value
if (!nextAnchor) {
return
}
this.isolatedWindowPaneId = nextAnchor.paneId
log("[tmux-session-manager] reassigned isolated container anchor pane", {
sessionId: nextAnchor.sessionId,
paneId: nextAnchor.paneId,
})
}
private async cleanupIsolatedContainerAfterSessionDeletion(
tracked: TrackedSession,
isolatedPaneAlreadyClosed: boolean,
@@ -182,18 +198,25 @@ export class TmuxSessionManager {
}
if (this.sessions.size > 0) {
this.reassignIsolatedContainerAnchor()
return
}
const isolatedContainerPaneId = this.isolatedContainerPaneId
this.isolatedContainerPaneId = undefined
this.isolatedWindowPaneId = undefined
if (isolatedPaneAlreadyClosed) {
if (!isolatedContainerPaneId) {
return
}
if (isolatedPaneAlreadyClosed && tracked.paneId === isolatedContainerPaneId) {
return
}
try {
const result = await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
{ type: "close", paneId: isolatedContainerPaneId, sessionId: tracked.sessionId },
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
@@ -205,13 +228,13 @@ export class TmuxSessionManager {
if (!result.success) {
log("[tmux-session-manager] failed to close isolated container pane after anchor session deletion", {
sessionId: tracked.sessionId,
paneId: tracked.paneId,
paneId: isolatedContainerPaneId,
})
}
} catch (error) {
log("[tmux-session-manager] failed to cleanup isolated container pane after anchor session deletion", {
sessionId: tracked.sessionId,
paneId: tracked.paneId,
paneId: isolatedContainerPaneId,
error: String(error),
})
}
@@ -837,6 +860,7 @@ export class TmuxSessionManager {
}
await this.retryPendingCloses()
this.isolatedContainerPaneId = undefined
this.isolatedWindowPaneId = undefined
log("[tmux-session-manager] cleanup complete")