From 84a83922c3662a0497f48896dbf05a6bce2b5bd6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 03:40:55 +0900 Subject: [PATCH] fix: stop tracking sessions that never become ready When session readiness times out, immediately close the spawned pane and skip tracking to prevent stale mappings from causing reopen and close anomalies. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/features/tmux-subagent/manager.test.ts | 54 ++++++++++++++++++- src/features/tmux-subagent/manager.ts | 9 +++- .../tmux-subagent/session-created-handler.ts | 13 ++++- src/features/tmux-subagent/session-spawner.ts | 13 ++++- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index b28c2cf7..e1bf6e51 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -434,6 +434,53 @@ describe('TmuxSessionManager', () => { }) describe('onSessionDeleted', () => { + test('does not track session when readiness timed out', async () => { + // given + mockIsInsideTmux.mockReturnValue(true) + let stateCallCount = 0 + mockQueryWindowState.mockImplementation(async () => { + stateCallCount++ + if (stateCallCount === 1) { + return createWindowState() + } + return createWindowState({ + agentPanes: [ + { + paneId: '%mock', + width: 40, + height: 44, + left: 100, + top: 0, + title: 'omo-subagent-Timeout Task', + isActive: false, + }, + ], + }) + }) + + const { TmuxSessionManager } = await import('./manager') + const ctx = createMockContext({ sessionStatusResult: { data: {} } }) + const config: TmuxConfig = { + enabled: true, + 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_timeout', 'ses_parent', 'Timeout Task') + ) + mockExecuteAction.mockClear() + + // when + await manager.onSessionDeleted({ sessionID: 'ses_timeout' }) + + // then + expect(mockExecuteAction).toHaveBeenCalledTimes(0) + }) + test('closes pane when tracked session is deleted', async () => { // given mockIsInsideTmux.mockReturnValue(true) @@ -521,8 +568,13 @@ describe('TmuxSessionManager', () => { mockIsInsideTmux.mockReturnValue(true) let callCount = 0 - mockExecuteActions.mockImplementation(async () => { + mockExecuteActions.mockImplementation(async (actions) => { callCount++ + for (const action of actions) { + if (action.type === 'spawn') { + trackedSessions.add(action.sessionId) + } + } return { success: true, spawnedPaneId: `%${callCount}`, diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 4ab167d5..2c40f7c9 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -213,10 +213,17 @@ export class TmuxSessionManager { const sessionReady = await this.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeAction( + { type: "close", paneId: result.spawnedPaneId, sessionId }, + { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state } + ) + + return } const now = Date.now() diff --git a/src/features/tmux-subagent/session-created-handler.ts b/src/features/tmux-subagent/session-created-handler.ts index 18afb0d9..a72816ea 100644 --- a/src/features/tmux-subagent/session-created-handler.ts +++ b/src/features/tmux-subagent/session-created-handler.ts @@ -135,10 +135,21 @@ export async function handleSessionCreated( const sessionReady = await deps.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeActions( + [{ type: "close", paneId: result.spawnedPaneId, sessionId }], + { + config: deps.tmuxConfig, + serverUrl: deps.serverUrl, + windowState: state, + }, + ) + + return } const now = Date.now() diff --git a/src/features/tmux-subagent/session-spawner.ts b/src/features/tmux-subagent/session-spawner.ts index 433a163f..4c43b653 100644 --- a/src/features/tmux-subagent/session-spawner.ts +++ b/src/features/tmux-subagent/session-spawner.ts @@ -129,10 +129,21 @@ export class SessionSpawner { const sessionReady = await this.waitForSessionReady(sessionId) if (!sessionReady) { - log("[tmux-session-manager] session not ready after timeout, tracking anyway", { + log("[tmux-session-manager] session not ready after timeout, closing spawned pane", { sessionId, paneId: result.spawnedPaneId, }) + + await executeActions( + [{ type: "close", paneId: result.spawnedPaneId, sessionId }], + { + config: this.tmuxConfig, + serverUrl: this.serverUrl, + windowState: state, + }, + ) + + return } const now = Date.now()