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 f3c8b0d098
commit 541f0d354d
14 changed files with 1156 additions and 227 deletions
+240 -15
View File
@@ -156,7 +156,15 @@ describe('TmuxSessionManager', () => {
// given
mockIsInsideTmux.mockReturnValue(true)
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const ctx = createMockContext({
sessionStatusResult: {
data: {
ses_1: { type: 'running' },
ses_2: { type: 'running' },
ses_3: { type: 'running' },
},
},
})
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
@@ -176,7 +184,13 @@ describe('TmuxSessionManager', () => {
// given
mockIsInsideTmux.mockReturnValue(false)
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const ctx = createMockContext({
sessionStatusResult: {
data: {
ses_once: { type: 'running' },
},
},
})
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
@@ -386,7 +400,7 @@ describe('TmuxSessionManager', () => {
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
})
test('replaces oldest agent when unsplittable (small window)', async () => {
test('defers attach when unsplittable (small window)', async () => {
// given - small window where split is not possible
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async () =>
@@ -423,13 +437,224 @@ describe('TmuxSessionManager', () => {
createSessionCreatedEvent('ses_new', 'ses_parent', 'New Task')
)
// then - with small window, replace action is used instead of close+spawn
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
const call = mockExecuteActions.mock.calls[0]
expect(call).toBeDefined()
const actionsArg = call![0]
expect(actionsArg).toHaveLength(1)
expect(actionsArg[0].type).toBe('replace')
// then - with small window, manager defers instead of replacing
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
expect((manager as any).deferredQueue).toEqual(['ses_new'])
})
test('keeps deferred queue idempotent for duplicate session.created events', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async () =>
createWindowState({
windowWidth: 160,
windowHeight: 11,
agentPanes: [
{
paneId: '%1',
width: 80,
height: 11,
left: 80,
top: 0,
title: 'old',
isActive: false,
},
],
})
)
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
// when
await manager.onSessionCreated(
createSessionCreatedEvent('ses_dup', 'ses_parent', 'Duplicate Task')
)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_dup', 'ses_parent', 'Duplicate Task')
)
// then
expect((manager as any).deferredQueue).toEqual(['ses_dup'])
})
test('auto-attaches deferred sessions in FIFO order', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async () =>
createWindowState({
windowWidth: 160,
windowHeight: 11,
agentPanes: [
{
paneId: '%1',
width: 80,
height: 11,
left: 80,
top: 0,
title: 'old',
isActive: false,
},
],
})
)
const attachOrder: string[] = []
mockExecuteActions.mockImplementation(async (actions) => {
for (const action of actions) {
if (action.type === 'spawn') {
attachOrder.push(action.sessionId)
trackedSessions.add(action.sessionId)
return {
success: true,
spawnedPaneId: `%${action.sessionId}`,
results: [{ action, result: { success: true, paneId: `%${action.sessionId}` } }],
}
}
}
return { success: true, results: [] }
})
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1'))
await manager.onSessionCreated(createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2'))
await manager.onSessionCreated(createSessionCreatedEvent('ses_3', 'ses_parent', 'Task 3'))
expect((manager as any).deferredQueue).toEqual(['ses_1', 'ses_2', 'ses_3'])
// when
mockQueryWindowState.mockImplementation(async () => createWindowState())
await (manager as any).tryAttachDeferredSession()
await (manager as any).tryAttachDeferredSession()
await (manager as any).tryAttachDeferredSession()
// then
expect(attachOrder).toEqual(['ses_1', 'ses_2', 'ses_3'])
expect((manager as any).deferredQueue).toEqual([])
})
test('does not attach deferred session more than once across repeated retries', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async () =>
createWindowState({
windowWidth: 160,
windowHeight: 11,
agentPanes: [
{
paneId: '%1',
width: 80,
height: 11,
left: 80,
top: 0,
title: 'old',
isActive: false,
},
],
})
)
let attachCount = 0
mockExecuteActions.mockImplementation(async (actions) => {
for (const action of actions) {
if (action.type === 'spawn') {
attachCount += 1
trackedSessions.add(action.sessionId)
return {
success: true,
spawnedPaneId: `%${action.sessionId}`,
results: [{ action, result: { success: true, paneId: `%${action.sessionId}` } }],
}
}
}
return { success: true, results: [] }
})
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_once', 'ses_parent', 'Task Once')
)
// when
mockQueryWindowState.mockImplementation(async () => createWindowState())
await (manager as any).tryAttachDeferredSession()
await (manager as any).tryAttachDeferredSession()
// then
expect(attachCount).toBe(1)
expect((manager as any).deferredQueue).toEqual([])
})
test('removes deferred session when session is deleted before attach', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockQueryWindowState.mockImplementation(async () =>
createWindowState({
windowWidth: 160,
windowHeight: 11,
agentPanes: [
{
paneId: '%1',
width: 80,
height: 11,
left: 80,
top: 0,
title: 'old',
isActive: false,
},
],
})
)
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_pending', 'ses_parent', 'Pending Task')
)
expect((manager as any).deferredQueue).toEqual(['ses_pending'])
// when
await manager.onSessionDeleted({ sessionID: 'ses_pending' })
// then
expect((manager as any).deferredQueue).toEqual([])
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
})
})
@@ -680,7 +905,7 @@ describe('DecisionEngine', () => {
}
})
test('returns replace when split not possible', async () => {
test('returns canSpawn=false when split not possible', async () => {
// given - small window where split is never possible
const { decideSpawnActions } = await import('./decision-engine')
const state: WindowState = {
@@ -720,10 +945,10 @@ describe('DecisionEngine', () => {
sessionMappings
)
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so replace is used
expect(decision.canSpawn).toBe(true)
expect(decision.actions).toHaveLength(1)
expect(decision.actions[0].type).toBe('replace')
// then - agent area (80) < MIN_SPLIT_WIDTH (105), so attach is deferred
expect(decision.canSpawn).toBe(false)
expect(decision.actions).toHaveLength(0)
expect(decision.reason).toContain('defer')
})
test('returns canSpawn=false when window too small', async () => {