fix(tmux): prefer split-or-defer with FIFO deferred attach

This commit is contained in:
liu-qingyuan
2026-02-15 03:26:39 +08:00
parent fbb9e76ade
commit 7bfbea1a06
14 changed files with 1156 additions and 227 deletions
+253 -73
View File
@@ -19,6 +19,12 @@ interface SessionCreatedEvent {
properties?: { info?: { id?: string; parentID?: string; title?: string } }
}
interface DeferredSession {
sessionId: string
title: string
queuedAt: Date
}
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
@@ -48,6 +54,10 @@ export class TmuxSessionManager {
private sourcePaneId: string | undefined
private sessions = new Map<string, TrackedSession>()
private pendingSessions = new Set<string>()
private spawnQueue: Promise<void> = Promise.resolve()
private deferredSessions = new Map<string, DeferredSession>()
private deferredQueue: string[] = []
private deferredAttachInterval?: ReturnType<typeof setInterval>
private deps: TmuxUtilDeps
private pollingManager: TmuxPollingManager
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
@@ -75,6 +85,8 @@ export class TmuxSessionManager {
private getCapacityConfig(): CapacityConfig {
return {
layout: this.tmuxConfig.layout,
mainPaneSize: this.tmuxConfig.main_pane_size,
mainPaneMinWidth: this.tmuxConfig.main_pane_min_width,
agentPaneWidth: this.tmuxConfig.agent_pane_min_width,
}
@@ -88,6 +100,129 @@ export class TmuxSessionManager {
}))
}
private enqueueDeferredSession(sessionId: string, title: string): void {
if (this.deferredSessions.has(sessionId)) return
this.deferredSessions.set(sessionId, {
sessionId,
title,
queuedAt: new Date(),
})
this.deferredQueue.push(sessionId)
log("[tmux-session-manager] deferred session queued", {
sessionId,
queueLength: this.deferredQueue.length,
})
this.startDeferredAttachLoop()
}
private removeDeferredSession(sessionId: string): void {
if (!this.deferredSessions.delete(sessionId)) return
this.deferredQueue = this.deferredQueue.filter((id) => id !== sessionId)
log("[tmux-session-manager] deferred session removed", {
sessionId,
queueLength: this.deferredQueue.length,
})
if (this.deferredQueue.length === 0) {
this.stopDeferredAttachLoop()
}
}
private startDeferredAttachLoop(): void {
if (this.deferredAttachInterval) return
this.deferredAttachInterval = setInterval(() => {
void this.enqueueSpawn(async () => {
await this.tryAttachDeferredSession()
})
}, POLL_INTERVAL_BACKGROUND_MS)
log("[tmux-session-manager] deferred attach polling started", {
intervalMs: POLL_INTERVAL_BACKGROUND_MS,
})
}
private stopDeferredAttachLoop(): void {
if (!this.deferredAttachInterval) return
clearInterval(this.deferredAttachInterval)
this.deferredAttachInterval = undefined
log("[tmux-session-manager] deferred attach polling stopped")
}
private async tryAttachDeferredSession(): Promise<void> {
if (!this.sourcePaneId) return
const sessionId = this.deferredQueue[0]
if (!sessionId) {
this.stopDeferredAttachLoop()
return
}
const deferred = this.deferredSessions.get(sessionId)
if (!deferred) {
this.deferredQueue.shift()
return
}
const state = await queryWindowState(this.sourcePaneId)
if (!state) return
const decision = decideSpawnActions(
state,
sessionId,
deferred.title,
this.getCapacityConfig(),
this.getSessionMappings(),
)
if (!decision.canSpawn || decision.actions.length === 0) {
log("[tmux-session-manager] deferred session still waiting for capacity", {
sessionId,
reason: decision.reason,
})
return
}
const result = await executeActions(decision.actions, {
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
})
if (!result.success || !result.spawnedPaneId) {
log("[tmux-session-manager] deferred session attach failed", {
sessionId,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
return
}
const sessionReady = await this.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] deferred session not ready after timeout", {
sessionId,
paneId: result.spawnedPaneId,
})
}
const now = Date.now()
this.sessions.set(sessionId, {
sessionId,
paneId: result.spawnedPaneId,
description: deferred.title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
this.removeDeferredSession(sessionId)
this.pollingManager.startPolling()
log("[tmux-session-manager] deferred session attached", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
}
private async waitForSessionReady(sessionId: string): Promise<boolean> {
const startTime = Date.now()
@@ -138,7 +273,11 @@ export class TmuxSessionManager {
const sessionId = info.id
const title = info.title ?? "Subagent"
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
if (
this.sessions.has(sessionId) ||
this.pendingSessions.has(sessionId) ||
this.deferredSessions.has(sessionId)
) {
log("[tmux-session-manager] session already tracked or pending", { sessionId })
return
}
@@ -147,15 +286,17 @@ export class TmuxSessionManager {
log("[tmux-session-manager] no source pane id")
return
}
const sourcePaneId = this.sourcePaneId
this.pendingSessions.add(sessionId)
try {
const state = await queryWindowState(this.sourcePaneId)
if (!state) {
log("[tmux-session-manager] failed to query window state")
return
}
await this.enqueueSpawn(async () => {
try {
const state = await queryWindowState(sourcePaneId)
if (!state) {
log("[tmux-session-manager] failed to query window state")
return
}
log("[tmux-session-manager] window state queried", {
windowWidth: state.windowWidth,
@@ -164,13 +305,13 @@ export class TmuxSessionManager {
agentPanes: state.agentPanes.map((p) => p.paneId),
})
const decision = decideSpawnActions(
state,
sessionId,
title,
this.getCapacityConfig(),
this.getSessionMappings()
)
const decision = decideSpawnActions(
state,
sessionId,
title,
this.getCapacityConfig(),
this.getSessionMappings()
)
log("[tmux-session-manager] spawn decision", {
canSpawn: decision.canSpawn,
@@ -183,39 +324,70 @@ export class TmuxSessionManager {
}),
})
if (!decision.canSpawn) {
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
return
}
const result = await executeActions(
decision.actions,
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
)
for (const { action, result: actionResult } of result.results) {
if (action.type === "close" && actionResult.success) {
this.sessions.delete(action.sessionId)
log("[tmux-session-manager] removed closed session from cache", {
sessionId: action.sessionId,
})
if (!decision.canSpawn) {
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
this.enqueueDeferredSession(sessionId, title)
return
}
if (action.type === "replace" && actionResult.success) {
this.sessions.delete(action.oldSessionId)
log("[tmux-session-manager] removed replaced session from cache", {
oldSessionId: action.oldSessionId,
newSessionId: action.newSessionId,
})
}
}
if (result.success && result.spawnedPaneId) {
const sessionReady = await this.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] session not ready after timeout, closing spawned pane", {
const result = await executeActions(
decision.actions,
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId,
}
)
for (const { action, result: actionResult } of result.results) {
if (action.type === "close" && actionResult.success) {
this.sessions.delete(action.sessionId)
log("[tmux-session-manager] removed closed session from cache", {
sessionId: action.sessionId,
})
}
if (action.type === "replace" && actionResult.success) {
this.sessions.delete(action.oldSessionId)
log("[tmux-session-manager] removed replaced session from cache", {
oldSessionId: action.oldSessionId,
newSessionId: action.newSessionId,
})
}
}
if (result.success && result.spawnedPaneId) {
const sessionReady = await this.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
sessionId,
paneId: result.spawnedPaneId,
})
}
const now = Date.now()
this.sessions.set(sessionId, {
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
this.pollingManager.startPolling()
} else {
log("[tmux-session-manager] spawn failed", {
success: result.success,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
await executeAction(
@@ -225,40 +397,30 @@ export class TmuxSessionManager {
return
}
const now = Date.now()
this.sessions.set(sessionId, {
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
this.pollingManager.startPolling()
} else {
log("[tmux-session-manager] spawn failed", {
success: result.success,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
} finally {
this.pendingSessions.delete(sessionId)
}
} finally {
this.pendingSessions.delete(sessionId)
}
})
}
private async enqueueSpawn(run: () => Promise<void>): Promise<void> {
this.spawnQueue = this.spawnQueue
.catch(() => undefined)
.then(run)
.catch((err) => {
log("[tmux-session-manager] spawn queue task failed", {
error: String(err),
})
})
await this.spawnQueue
}
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
if (!this.isEnabled()) return
if (!this.sourcePaneId) return
this.removeDeferredSession(event.sessionID)
const tracked = this.sessions.get(event.sessionID)
if (!tracked) return
@@ -272,7 +434,12 @@ export class TmuxSessionManager {
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
if (closeAction) {
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
await executeAction(closeAction, {
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
})
}
this.sessions.delete(event.sessionID)
@@ -296,7 +463,12 @@ export class TmuxSessionManager {
if (state) {
await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
}
)
}
@@ -314,6 +486,9 @@ export class TmuxSessionManager {
}
async cleanup(): Promise<void> {
this.stopDeferredAttachLoop()
this.deferredQueue = []
this.deferredSessions.clear()
this.pollingManager.stopPolling()
if (this.sessions.size > 0) {
@@ -324,7 +499,12 @@ export class TmuxSessionManager {
const closePromises = Array.from(this.sessions.values()).map((s) =>
executeAction(
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
{
config: this.tmuxConfig,
serverUrl: this.serverUrl,
windowState: state,
sourcePaneId: this.sourcePaneId,
}
).catch((err) =>
log("[tmux-session-manager] cleanup error for pane", {
paneId: s.paneId,