From 52f62c3fdafe0dc5370a96061413f6b63245fb94 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 02:16:00 +0900 Subject: [PATCH 1/2] fix(tmux-deferred): add TTL/max-size guards, null-state exit, and spawn atomicity - BUG-3: Add DEFERRED_SESSION_TTL_MS (5min) and MAX_DEFERRED_QUEUE_SIZE (20) to prevent unbounded growth - BUG-15: Track consecutive null window states, stop polling after 3 nulls to prevent immortal loop - BUG-6: Track close+spawn failure and re-queue deferred session for retry --- src/features/tmux-subagent/manager.ts | 56 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index c73f6539..06fc16af 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -36,6 +36,9 @@ const defaultTmuxDeps: TmuxUtilDeps = { getCurrentPaneId: defaultGetCurrentPaneId, } +const DEFERRED_SESSION_TTL_MS = 5 * 60 * 1000 +const MAX_DEFERRED_QUEUE_SIZE = 20 + /** * State-first Tmux Session Manager * @@ -60,6 +63,7 @@ export class TmuxSessionManager { private deferredQueue: string[] = [] private deferredAttachInterval?: ReturnType private deferredAttachTickScheduled = false + private nullStateCount = 0 private deps: TmuxUtilDeps private pollingManager: TmuxPollingManager constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) { @@ -104,6 +108,14 @@ export class TmuxSessionManager { private enqueueDeferredSession(sessionId: string, title: string): void { if (this.deferredSessions.has(sessionId)) return + if (this.deferredQueue.length >= MAX_DEFERRED_QUEUE_SIZE) { + log("[tmux-session-manager] deferred queue full, dropping session", { + sessionId, + queueLength: this.deferredQueue.length, + maxQueueSize: MAX_DEFERRED_QUEUE_SIZE, + }) + return + } this.deferredSessions.set(sessionId, { sessionId, title, @@ -131,6 +143,7 @@ export class TmuxSessionManager { private startDeferredAttachLoop(): void { if (this.deferredAttachInterval) return + this.nullStateCount = 0 this.deferredAttachInterval = setInterval(() => { if (this.deferredAttachTickScheduled) return this.deferredAttachTickScheduled = true @@ -152,6 +165,7 @@ export class TmuxSessionManager { clearInterval(this.deferredAttachInterval) this.deferredAttachInterval = undefined this.deferredAttachTickScheduled = false + this.nullStateCount = 0 log("[tmux-session-manager] deferred attach polling stopped") } @@ -169,8 +183,36 @@ export class TmuxSessionManager { return } + if (Date.now() - deferred.queuedAt.getTime() > DEFERRED_SESSION_TTL_MS) { + this.deferredQueue.shift() + this.deferredSessions.delete(sessionId) + log("[tmux-session-manager] deferred session expired", { + sessionId, + queuedAt: deferred.queuedAt.toISOString(), + ttlMs: DEFERRED_SESSION_TTL_MS, + queueLength: this.deferredQueue.length, + }) + if (this.deferredQueue.length === 0) { + this.stopDeferredAttachLoop() + } + return + } + const state = await queryWindowState(this.sourcePaneId) - if (!state) return + if (!state) { + this.nullStateCount += 1 + log("[tmux-session-manager] deferred attach window state is null", { + nullStateCount: this.nullStateCount, + }) + if (this.nullStateCount >= 3) { + log("[tmux-session-manager] stopping deferred attach loop after consecutive null states", { + nullStateCount: this.nullStateCount, + }) + this.stopDeferredAttachLoop() + } + return + } + this.nullStateCount = 0 const decision = decideSpawnActions( state, @@ -365,6 +407,11 @@ export class TmuxSessionManager { } } + const closeActionSucceeded = result.results.some( + ({ action, result: actionResult }) => action.type === "close" && actionResult.success, + ) + const spawnFailed = !result.success || !result.spawnedPaneId + if (result.success && result.spawnedPaneId) { const sessionReady = await this.waitForSessionReady(sessionId) @@ -399,6 +446,13 @@ export class TmuxSessionManager { })), }) + if (closeActionSucceeded) { + log("[tmux-session-manager] re-queueing deferred session after close+spawn failure", { + sessionId, + }) + this.enqueueDeferredSession(sessionId, title) + } + if (result.spawnedPaneId) { await executeAction( { type: "close", paneId: result.spawnedPaneId, sessionId }, From 148687c7fe185f8f76e93d245ee62eee27c3dd9b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 02:34:34 +0900 Subject: [PATCH 2/2] fix: remove unused spawnFailed variable (dead code) --- src/features/tmux-subagent/manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index 06fc16af..eef70b19 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -410,7 +410,6 @@ export class TmuxSessionManager { const closeActionSucceeded = result.results.some( ({ action, result: actionResult }) => action.type === "close" && actionResult.success, ) - const spawnFailed = !result.success || !result.spawnedPaneId if (result.success && result.spawnedPaneId) { const sessionReady = await this.waitForSessionReady(sessionId)