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