test(tmux-subagent): expand manager test coverage
This commit is contained in:
@@ -17,6 +17,11 @@ type SpawnTmuxContainerResult = {
|
|||||||
paneId?: string
|
paneId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionReadyWaitParams = {
|
||||||
|
client: unknown
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(
|
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(
|
||||||
async () => ({
|
async () => ({
|
||||||
windowWidth: 212,
|
windowWidth: 212,
|
||||||
@@ -38,6 +43,13 @@ const mockExecuteAction = mock<(
|
|||||||
action: PaneAction,
|
action: PaneAction,
|
||||||
ctx: ExecuteContext
|
ctx: ExecuteContext
|
||||||
) => Promise<ActionResult>>(async () => ({ success: true }))
|
) => Promise<ActionResult>>(async () => ({ success: true }))
|
||||||
|
const mockSpawnTmuxPane = mock(async (_sessionId?: string) => ({
|
||||||
|
success: true,
|
||||||
|
paneId: '%mock',
|
||||||
|
}))
|
||||||
|
const mockWaitForSessionReady = mock<(
|
||||||
|
params: SessionReadyWaitParams,
|
||||||
|
) => Promise<boolean>>(async () => true)
|
||||||
const mockSpawnTmuxWindow = mock<(
|
const mockSpawnTmuxWindow = mock<(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
description: string,
|
description: string,
|
||||||
@@ -65,49 +77,44 @@ const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
|||||||
const mockTmuxDeps: TmuxUtilDeps = {
|
const mockTmuxDeps: TmuxUtilDeps = {
|
||||||
isInsideTmux: mockIsInsideTmux,
|
isInsideTmux: mockIsInsideTmux,
|
||||||
getCurrentPaneId: mockGetCurrentPaneId,
|
getCurrentPaneId: mockGetCurrentPaneId,
|
||||||
|
queryWindowState: mockQueryWindowState,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock.module('./pane-state-querier', () => ({
|
function registerModuleMocks(): void {
|
||||||
queryWindowState: mockQueryWindowState,
|
mock.module('./action-executor', () => ({
|
||||||
paneExists: mockPaneExists,
|
executeActions: mockExecuteActions,
|
||||||
getRightmostAgentPane: (state: WindowState) =>
|
executeAction: mockExecuteAction,
|
||||||
state.agentPanes.length > 0
|
executeActionWithDeps: mockExecuteAction,
|
||||||
? state.agentPanes.reduce((r, p) => (p.left > r.left ? p : r))
|
}))
|
||||||
: null,
|
|
||||||
getOldestAgentPane: (state: WindowState) =>
|
mock.module('./session-ready-waiter', () => ({
|
||||||
state.agentPanes.length > 0
|
waitForSessionReady: mockWaitForSessionReady,
|
||||||
? state.agentPanes.reduce((o, p) => (p.left < o.left ? p : o))
|
}))
|
||||||
: null,
|
|
||||||
}))
|
mock.module('../../shared/tmux', () => {
|
||||||
|
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
|
||||||
|
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
|
||||||
|
return {
|
||||||
|
isInsideTmux,
|
||||||
|
getCurrentPaneId,
|
||||||
|
POLL_INTERVAL_BACKGROUND_MS,
|
||||||
|
SESSION_TIMEOUT_MS,
|
||||||
|
SESSION_MISSING_GRACE_MS,
|
||||||
|
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||||
|
SESSION_READY_TIMEOUT_MS: 500,
|
||||||
|
spawnTmuxWindow: mockSpawnTmuxWindow,
|
||||||
|
spawnTmuxSession: mockSpawnTmuxSession,
|
||||||
|
killTmuxSessionIfExists: mockKillTmuxSessionIfExists,
|
||||||
|
getIsolatedSessionName: (pid: number = 12345) => `omo-agents-${pid}`,
|
||||||
|
sweepStaleOmoAgentSessions: mockSweepStaleOmoAgentSessions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
afterAll(() => { mock.restore() })
|
afterAll(() => { mock.restore() })
|
||||||
|
|
||||||
mock.module('./action-executor', () => ({
|
|
||||||
executeActions: mockExecuteActions,
|
|
||||||
executeAction: mockExecuteAction,
|
|
||||||
executeActionWithDeps: mockExecuteAction,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../shared/tmux', () => {
|
|
||||||
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
|
|
||||||
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
|
|
||||||
return {
|
|
||||||
isInsideTmux,
|
|
||||||
getCurrentPaneId,
|
|
||||||
POLL_INTERVAL_BACKGROUND_MS,
|
|
||||||
SESSION_TIMEOUT_MS,
|
|
||||||
SESSION_MISSING_GRACE_MS,
|
|
||||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
|
||||||
SESSION_READY_TIMEOUT_MS: 500,
|
|
||||||
spawnTmuxWindow: mockSpawnTmuxWindow,
|
|
||||||
spawnTmuxSession: mockSpawnTmuxSession,
|
|
||||||
killTmuxSessionIfExists: mockKillTmuxSessionIfExists,
|
|
||||||
getIsolatedSessionName: (pid: number = 12345) => `omo-agents-${pid}`,
|
|
||||||
sweepStaleOmoAgentSessions: mockSweepStaleOmoAgentSessions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const trackedSessions = new Set<string>()
|
const trackedSessions = new Set<string>()
|
||||||
|
const readySessions = new Set<string>()
|
||||||
|
|
||||||
function createMockContext(overrides?: {
|
function createMockContext(overrides?: {
|
||||||
sessionStatusResult?: { data?: Record<string, { type: string }> }
|
sessionStatusResult?: { data?: Record<string, { type: string }> }
|
||||||
@@ -125,6 +132,9 @@ function createMockContext(overrides?: {
|
|||||||
for (const sessionId of trackedSessions) {
|
for (const sessionId of trackedSessions) {
|
||||||
data[sessionId] = { type: 'running' }
|
data[sessionId] = { type: 'running' }
|
||||||
}
|
}
|
||||||
|
for (const sessionId of readySessions) {
|
||||||
|
data[sessionId] = { type: 'running' }
|
||||||
|
}
|
||||||
return { data }
|
return { data }
|
||||||
}),
|
}),
|
||||||
messages: mock(async () => {
|
messages: mock(async () => {
|
||||||
@@ -161,6 +171,28 @@ function createWindowState(overrides?: Partial<WindowState>): WindowState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<TValue>() {
|
||||||
|
let resolvePromise!: (value: TValue | PromiseLike<TValue>) => void
|
||||||
|
let rejectPromise!: (reason?: unknown) => void
|
||||||
|
|
||||||
|
const promise = new Promise<TValue>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
rejectPromise = reject
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve: resolvePromise,
|
||||||
|
reject: rejectPromise,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(turns: number = 5): Promise<void> {
|
||||||
|
for (let index = 0; index < turns; index += 1) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createTmuxConfig(overrides?: Partial<TmuxConfig>): TmuxConfig {
|
function createTmuxConfig(overrides?: Partial<TmuxConfig>): TmuxConfig {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -177,29 +209,57 @@ function getTrackedSessions(manager: object): Map<string, { paneId: string; clos
|
|||||||
return Reflect.get(manager, 'sessions') as Map<string, { paneId: string; closePending: boolean; closeRetryCount: number }>
|
return Reflect.get(manager, 'sessions') as Map<string, { paneId: string; closePending: boolean; closeRetryCount: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFailedReadinessSessions(manager: object): Map<string, { sessionId: string; title: string }> {
|
||||||
|
return Reflect.get(manager, 'failedReadinessSessions') as Map<string, { sessionId: string; title: string }>
|
||||||
|
}
|
||||||
|
|
||||||
describe('TmuxSessionManager', () => {
|
describe('TmuxSessionManager', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
registerModuleMocks()
|
||||||
mockQueryWindowState.mockClear()
|
mockQueryWindowState.mockClear()
|
||||||
mockPaneExists.mockClear()
|
mockPaneExists.mockClear()
|
||||||
mockExecuteActions.mockClear()
|
mockExecuteActions.mockClear()
|
||||||
mockExecuteAction.mockClear()
|
mockExecuteAction.mockClear()
|
||||||
|
mockSpawnTmuxPane.mockClear()
|
||||||
|
mockWaitForSessionReady.mockClear()
|
||||||
mockSpawnTmuxWindow.mockClear()
|
mockSpawnTmuxWindow.mockClear()
|
||||||
mockSpawnTmuxSession.mockClear()
|
mockSpawnTmuxSession.mockClear()
|
||||||
mockIsInsideTmux.mockClear()
|
mockIsInsideTmux.mockClear()
|
||||||
mockGetCurrentPaneId.mockClear()
|
mockGetCurrentPaneId.mockClear()
|
||||||
trackedSessions.clear()
|
trackedSessions.clear()
|
||||||
|
readySessions.clear()
|
||||||
|
|
||||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
mockExecuteActions.mockImplementation(async (actions: PaneAction[]) => { for (const action of actions) {
|
mockExecuteActions.mockImplementation(async (actions: PaneAction[]) => {
|
||||||
if (action.type === 'spawn') {
|
const results: ExecuteActionsResult['results'] = []
|
||||||
trackedSessions.add(action.sessionId)
|
let spawnedPaneId: string | undefined
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.type === 'spawn') {
|
||||||
|
const spawnResult = await mockSpawnTmuxPane(action.sessionId)
|
||||||
|
if (!spawnResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
results: [{ action, result: { success: false, error: 'spawn failed' } }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackedSessions.add(action.sessionId)
|
||||||
|
spawnedPaneId = spawnResult.paneId
|
||||||
|
results.push({ action, result: { success: true, paneId: spawnResult.paneId } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
spawnedPaneId: '%mock',
|
spawnedPaneId: spawnedPaneId ?? '%mock',
|
||||||
results: [],
|
results,
|
||||||
} })
|
}
|
||||||
|
})
|
||||||
|
mockWaitForSessionReady.mockImplementation(async ({ sessionId }: SessionReadyWaitParams) => {
|
||||||
|
readySessions.add(sessionId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
mockSpawnTmuxWindow.mockImplementation(async (sessionId: string) => {
|
mockSpawnTmuxWindow.mockImplementation(async (sessionId: string) => {
|
||||||
trackedSessions.add(sessionId)
|
trackedSessions.add(sessionId)
|
||||||
return {
|
return {
|
||||||
@@ -382,6 +442,50 @@ describe('TmuxSessionManager', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getServerUrl', () => {
|
||||||
|
test('returns normalized serverUrl from ctx', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = {
|
||||||
|
...createMockContext(),
|
||||||
|
serverUrl: new URL('http://127.0.0.1:12345/'),
|
||||||
|
}
|
||||||
|
const config = createTmuxConfig({ enabled: true })
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const serverUrl = manager.getServerUrl()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(serverUrl).toBe('http://127.0.0.1:12345/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns fallback when port is 0', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const originalPort = process.env.OPENCODE_PORT
|
||||||
|
delete process.env.OPENCODE_PORT
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const ctx = {
|
||||||
|
...createMockContext(),
|
||||||
|
serverUrl: new URL('http://127.0.0.1:0/'),
|
||||||
|
}
|
||||||
|
const config = createTmuxConfig({ enabled: true })
|
||||||
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const serverUrl = manager.getServerUrl()
|
||||||
|
|
||||||
|
// then
|
||||||
|
try {
|
||||||
|
expect(serverUrl).toBe(`http://localhost:${process.env.OPENCODE_PORT ?? '4096'}`)
|
||||||
|
} finally {
|
||||||
|
if (originalPort !== undefined) process.env.OPENCODE_PORT = originalPort
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('onSessionCreated', () => {
|
describe('onSessionCreated', () => {
|
||||||
test('first agent spawns from source pane via decision engine', async () => {
|
test('first agent spawns from source pane via decision engine', async () => {
|
||||||
// given
|
// given
|
||||||
@@ -867,6 +971,86 @@ describe('TmuxSessionManager', () => {
|
|||||||
expect((manager as any).deferredQueue).toEqual([])
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('skips deferred attach when the session is already pending through another spawn path', 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 manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({ enabled: true }), mockTmuxDeps)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_pending_race', 'ses_parent', 'Pending Race Task')
|
||||||
|
)
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_pending_race'])
|
||||||
|
|
||||||
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
|
Reflect.get(manager, 'pendingSessions').add('ses_pending_race')
|
||||||
|
|
||||||
|
// when
|
||||||
|
await Reflect.get(manager, 'tryAttachDeferredSession').call(manager)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_pending_race'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops deferred sessions that were already closed by polling', 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 manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({ enabled: true }), mockTmuxDeps)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_bounce', 'ses_parent', 'Bounce Task')
|
||||||
|
)
|
||||||
|
expect((manager as any).deferredQueue).toEqual(['ses_bounce'])
|
||||||
|
|
||||||
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
|
Reflect.set(manager, 'closedByPolling', new Set(['ses_bounce']))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await Reflect.get(manager, 'tryAttachDeferredSession').call(manager)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect((manager as any).deferredQueue).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('removes deferred session when session is deleted before attach', async () => {
|
test('removes deferred session when session is deleted before attach', async () => {
|
||||||
// given
|
// given
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
@@ -1137,55 +1321,304 @@ describe('TmuxSessionManager', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('#given session.status never reports session ready #when onSessionCreated runs #then pane is tracked immediately without blocking', async () => {
|
test('#given session readiness is pending #when onSessionCreated runs #then pane spawn waits until readiness resolves', async () => {
|
||||||
// given
|
// given
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||||
|
const readiness = createDeferred<boolean>()
|
||||||
|
mockWaitForSessionReady.mockImplementationOnce(async ({ sessionId }: SessionReadyWaitParams) => {
|
||||||
|
const ready = await readiness.promise
|
||||||
|
if (ready) {
|
||||||
|
readySessions.add(sessionId)
|
||||||
|
}
|
||||||
|
return ready
|
||||||
|
})
|
||||||
|
|
||||||
const { TmuxSessionManager } = await import('./manager')
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
const ctx = createMockContext({ sessionStatusResult: { data: {} } })
|
const ctx = createMockContext()
|
||||||
const config = createTmuxConfig({ enabled: true })
|
const config = createTmuxConfig({ enabled: true })
|
||||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||||
const event = createSessionCreatedEvent('ses_fast_track', 'ses_parent', 'Fast Track')
|
const event = createSessionCreatedEvent('ses_wait', 'ses_parent', 'Wait For Ready')
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const start = Date.now()
|
const onSessionCreatedPromise = manager.onSessionCreated(event)
|
||||||
await manager.onSessionCreated(event)
|
await flushMicrotasks()
|
||||||
const elapsed = Date.now() - start
|
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(elapsed < 500).toBe(true)
|
expect(mockWaitForSessionReady).toHaveBeenCalledTimes(1)
|
||||||
expect(getTrackedSessions(manager).has('ses_fast_track')).toBe(true)
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
|
// when
|
||||||
|
readiness.resolve(true)
|
||||||
|
await onSessionCreatedPromise
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getTrackedSessions(manager).has('ses_wait')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('#given readiness probe fails #when onSessionCreated runs #then it logs the structured error and does not spawn a pane', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const readinessError = new Error('session readiness timed out')
|
||||||
|
mockWaitForSessionReady.mockImplementationOnce(async () => {
|
||||||
|
throw readinessError
|
||||||
|
})
|
||||||
|
const logSpy = spyOn(sharedModule, 'log').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({ enabled: true }), mockTmuxDeps)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_timeout', 'ses_parent', 'Timeout Task')
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'[tmux-session-manager] session readiness failed before spawn',
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: 'ses_timeout',
|
||||||
|
stage: 'session.created',
|
||||||
|
error: String(readinessError),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
logSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips pane creation when session exists but status is 'error'", async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockWaitForSessionReady.mockImplementationOnce(async () => true)
|
||||||
|
const logSpy = spyOn(sharedModule, 'log').mockImplementation(() => {})
|
||||||
|
const sessionStatusResult = {
|
||||||
|
data: {
|
||||||
|
ses_error: { type: 'error' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(
|
||||||
|
createMockContext({ sessionStatusResult }),
|
||||||
|
createTmuxConfig({ enabled: true }),
|
||||||
|
mockTmuxDeps,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_error', 'ses_parent', 'Errored Session')
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect(getTrackedSessions(manager).has('ses_error')).toBe(false)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_error')).toBe(true)
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'[tmux-session-manager] session not attachable for pane spawn',
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: 'ses_error',
|
||||||
|
stage: 'session.created',
|
||||||
|
status: 'error',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
logSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries pane creation on session.idle after a readiness timeout when status becomes attachable', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const readinessError = new Error('session readiness timed out')
|
||||||
|
mockWaitForSessionReady
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
throw readinessError
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async () => true)
|
||||||
|
const sessionStatusResult = {
|
||||||
|
data: {} as Record<string, { type: string }>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(
|
||||||
|
createMockContext({ sessionStatusResult }),
|
||||||
|
createTmuxConfig({ enabled: true }),
|
||||||
|
mockTmuxDeps,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_retry', 'ses_parent', 'Retry Session')
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_retry')).toBe(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
sessionStatusResult.data.ses_retry = { type: 'idle' }
|
||||||
|
manager.onEvent({ type: 'session.idle', properties: { sessionID: 'ses_retry' } })
|
||||||
|
await flushMicrotasks(20)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getTrackedSessions(manager).has('ses_retry')).toBe(true)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_retry')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not retry more than once per sessionID', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
mockWaitForSessionReady
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('session readiness timed out')
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async () => true)
|
||||||
|
const sessionStatusResult = {
|
||||||
|
data: {
|
||||||
|
ses_retry_once: { type: 'idle' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(
|
||||||
|
createMockContext({ sessionStatusResult }),
|
||||||
|
createTmuxConfig({ enabled: true }),
|
||||||
|
mockTmuxDeps,
|
||||||
|
)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_retry_once', 'ses_parent', 'Retry Once Session')
|
||||||
|
)
|
||||||
|
manager.onEvent({ type: 'session.idle', properties: { sessionID: 'ses_retry_once' } })
|
||||||
|
await flushMicrotasks(20)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_retry_once')).toBe(false)
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.onEvent({ type: 'session.idle', properties: { sessionID: 'ses_retry_once' } })
|
||||||
|
await flushMicrotasks(20)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expires failed readiness sessions after the TTL elapses', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const nowSpy = spyOn(Date, 'now')
|
||||||
|
nowSpy.mockReturnValue(0)
|
||||||
|
mockWaitForSessionReady.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('session readiness timed out')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(
|
||||||
|
createMockContext({ sessionStatusResult: { data: { ses_expired: { type: 'idle' } } } }),
|
||||||
|
createTmuxConfig({ enabled: true }),
|
||||||
|
mockTmuxDeps,
|
||||||
|
)
|
||||||
|
|
||||||
|
await manager.onSessionCreated(
|
||||||
|
createSessionCreatedEvent('ses_expired', 'ses_parent', 'Expired Retry Session')
|
||||||
|
)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_expired')).toBe(true)
|
||||||
|
|
||||||
|
// when
|
||||||
|
nowSpy.mockReturnValue(5 * 60 * 1000 + 1)
|
||||||
|
manager.onEvent({ type: 'session.idle', properties: { sessionID: 'ses_expired' } })
|
||||||
|
await flushMicrotasks(20)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_expired')).toBe(false)
|
||||||
|
|
||||||
|
nowSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not retry failed readiness sessions after polling marked the session closed', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(
|
||||||
|
createMockContext({ sessionStatusResult: { data: { ses_bounce: { type: 'idle' } } } }),
|
||||||
|
createTmuxConfig({ enabled: true }),
|
||||||
|
mockTmuxDeps,
|
||||||
|
)
|
||||||
|
|
||||||
|
Reflect.get(manager, 'failedReadinessSessions').set('ses_bounce', {
|
||||||
|
sessionId: 'ses_bounce',
|
||||||
|
title: 'Bounce Session',
|
||||||
|
rememberedAt: Date.now(),
|
||||||
|
})
|
||||||
|
Reflect.set(manager, 'closedByPolling', new Set(['ses_bounce']))
|
||||||
|
|
||||||
|
// when
|
||||||
|
manager.onEvent({ type: 'session.idle', properties: { sessionID: 'ses_bounce' } })
|
||||||
|
await flushMicrotasks(20)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
expect(getFailedReadinessSessions(manager).has('ses_bounce')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('#given duplicate session.created triggers while readiness is pending #when readiness resolves #then only one pane spawn runs', async () => {
|
||||||
|
// given
|
||||||
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
|
const readiness = createDeferred<boolean>()
|
||||||
|
mockWaitForSessionReady.mockImplementationOnce(async ({ sessionId }: SessionReadyWaitParams) => {
|
||||||
|
const ready = await readiness.promise
|
||||||
|
if (ready) {
|
||||||
|
readySessions.add(sessionId)
|
||||||
|
}
|
||||||
|
return ready
|
||||||
|
})
|
||||||
|
|
||||||
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
|
const manager = new TmuxSessionManager(createMockContext(), createTmuxConfig({ enabled: true }), mockTmuxDeps)
|
||||||
|
const event = createSessionCreatedEvent('ses_dup_pending', 'ses_parent', 'Duplicate Pending')
|
||||||
|
|
||||||
|
// when
|
||||||
|
const firstSpawnPromise = manager.onSessionCreated(event)
|
||||||
|
const secondSpawnPromise = manager.onSessionCreated(event)
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockWaitForSessionReady).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||||
|
|
||||||
|
// when
|
||||||
|
readiness.resolve(true)
|
||||||
|
await Promise.all([firstSpawnPromise, secondSpawnPromise])
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(mockWaitForSessionReady).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getTrackedSessions(manager).has('ses_dup_pending')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('onSessionDeleted', () => {
|
describe('onSessionDeleted', () => {
|
||||||
test('does not track session when readiness timed out', async () => {
|
test('does nothing when session creation stopped before tracking due to readiness failure', async () => {
|
||||||
// given
|
// given
|
||||||
mockIsInsideTmux.mockReturnValue(true)
|
mockIsInsideTmux.mockReturnValue(true)
|
||||||
let stateCallCount = 0
|
mockWaitForSessionReady.mockImplementationOnce(async () => {
|
||||||
mockQueryWindowState.mockImplementation(async () => {
|
throw new Error('readiness failed')
|
||||||
stateCallCount++
|
|
||||||
if (stateCallCount === 1) {
|
|
||||||
return createWindowState()
|
|
||||||
}
|
|
||||||
return createWindowState({
|
|
||||||
agentPanes: [
|
|
||||||
{
|
|
||||||
paneId: '%mock',
|
|
||||||
width: 40,
|
|
||||||
height: 44,
|
|
||||||
left: 100,
|
|
||||||
top: 0,
|
|
||||||
title: 'omo-subagent-Timeout Task',
|
|
||||||
isActive: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { TmuxSessionManager } = await import('./manager')
|
const { TmuxSessionManager } = await import('./manager')
|
||||||
const ctx = createMockContext({ sessionStatusResult: { data: {} } })
|
const ctx = createMockContext()
|
||||||
const config = createTmuxConfig({ enabled: true,
|
const config = createTmuxConfig({ enabled: true,
|
||||||
layout: 'main-vertical',
|
layout: 'main-vertical',
|
||||||
main_pane_size: 60,
|
main_pane_size: 60,
|
||||||
@@ -1202,7 +1635,7 @@ describe('TmuxSessionManager', () => {
|
|||||||
await manager.onSessionDeleted({ sessionID: 'ses_timeout' })
|
await manager.onSessionDeleted({ sessionID: 'ses_timeout' })
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockExecuteAction).toHaveBeenCalledTimes(1)
|
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('closes pane when tracked session is deleted', async () => {
|
test('closes pane when tracked session is deleted', async () => {
|
||||||
@@ -2064,7 +2497,8 @@ describe('TmuxSessionManager', () => {
|
|||||||
const cleanupPromise = manager.cleanup()
|
const cleanupPromise = manager.cleanup()
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(await cleanupPromise).toBeUndefined()
|
const cleanupResult = await cleanupPromise
|
||||||
|
expect(cleanupResult).toBeUndefined()
|
||||||
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(1)
|
expect(mockKillTmuxSessionIfExists).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user