diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index e8dae0e93..72cf5cc7e 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -196,25 +196,30 @@ export class TmuxSessionManager { continue } - const nextRetryCount = tracked.closeRetryCount + 1 - if (nextRetryCount >= MAX_CLOSE_RETRY_COUNT) { - log("[tmux-session-manager] force removing close-pending session after failed retry", { - sessionId: tracked.sessionId, - paneId: tracked.paneId, - closeRetryCount: nextRetryCount, - }) - this.removeTrackedSession(tracked.sessionId) + const currentTracked = this.sessions.get(tracked.sessionId) + if (!currentTracked || !currentTracked.closePending) { continue } - this.sessions.set(tracked.sessionId, { - ...tracked, + 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: tracked.sessionId, - paneId: tracked.paneId, + sessionId: currentTracked.sessionId, + paneId: currentTracked.paneId, closeRetryCount: nextRetryCount, }) } @@ -642,6 +647,16 @@ 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, diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 90aff60fc..932f7e9c5 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -134,6 +134,15 @@ function getRetryPendingCloses(target: object): () => Promise { return retryPendingCloses.bind(target) } +function getCloseSessionById(target: object): (sessionId: string) => Promise { + 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 { @@ -219,4 +228,44 @@ describe("TmuxSessionManager zombie pane handling", () => { 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) + }) })