fix(tmux): prefer split-or-defer with FIFO deferred attach
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user